Merge remote-tracking branch 'origin/main' into feat/mail-reading
This commit is contained in:
@@ -24,3 +24,5 @@ modules/mesh-files/cmd/mesh-files/mesh-files
|
||||
|
||||
# The provisioner client built in place (issue 671).
|
||||
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
|
||||
@@ -160,8 +160,14 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != "" && r.Path != "" {
|
||||
switch {
|
||||
case r.ID != "" && r.Path != "":
|
||||
dirs[r.ID] = r.Path
|
||||
case r.ID != "" && r.Type == "directory":
|
||||
// A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is
|
||||
// the module's own, so a path under it belongs to this module and to no other's directory of the same
|
||||
// id (issue 1484: two runs-as modules each keep a .ssh in their own state directory).
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
|
||||
@@ -130,3 +130,18 @@ func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's
|
||||
// directory of the same id, and a path under it, are not inside it.
|
||||
func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"first", "second"} {
|
||||
manifest(t, root, name, mod(name,
|
||||
`{"id":"state","type":"directory","place":".","unknown":"program"}`,
|
||||
dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`),
|
||||
`{"id":"conf","type":"file","path":"${dir:ssh}/config"}`))
|
||||
}
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-6
@@ -5,7 +5,7 @@
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a
|
||||
// place, a volume. Exits 1 on a finding.
|
||||
// place, a volume; a path inside a directory of the module's own (`${dir:<id>}`) is not ~/.ssh. Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHModule is the one module allowed to name a path under ~/.ssh.
|
||||
@@ -60,7 +61,7 @@ func Check(root string) ([]Finding, error) {
|
||||
if module == SSHModule {
|
||||
continue
|
||||
}
|
||||
walk(m, "", func(key, value string) {
|
||||
walk(m, "", dirsOf(m), func(key, value string) {
|
||||
out = append(out, Finding{Module: module, Key: key, Value: value})
|
||||
})
|
||||
}
|
||||
@@ -68,7 +69,7 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
|
||||
// walk calls found for every string under v, outside Prose, that names a path under ~/.ssh.
|
||||
func walk(v any, key string, found func(key, value string)) {
|
||||
func walk(v any, key string, dirs map[string]string, found func(key, value string)) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
@@ -78,20 +79,68 @@ func walk(v any, key string, found func(key, value string)) {
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if !Prose[k] {
|
||||
walk(x[k], k, found)
|
||||
walk(x[k], k, dirs, found)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e, key, found)
|
||||
walk(e, key, dirs, found)
|
||||
}
|
||||
case string:
|
||||
if underSSH.MatchString(x) {
|
||||
// A path under a directory the mesh places for the module (${dir:<id>} of a directory with a place and no path,
|
||||
// as a runs-as module's home) is that account's own .ssh, not the operator's ~/.ssh that ssh-client sweeps
|
||||
// (issue 1484, ADR 0466 §4). Any other ${dir:<id>} is resolved to the path its resource declares and judged.
|
||||
if resolved, placed := resolveDir(x, dirs); !placed && underSSH.MatchString(resolved) {
|
||||
found(key, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ofDir = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// placedDir marks a directory the mesh places for the module: it has no path of its own.
|
||||
const placedDir = "\x00placed"
|
||||
|
||||
// dirsOf are the module's directories by id: the path each declares, or placedDir for one with a place and no path.
|
||||
func dirsOf(m any) map[string]string {
|
||||
dirs := map[string]string{}
|
||||
obj, _ := m.(map[string]any)
|
||||
list, _ := obj["resources"].([]any)
|
||||
for _, r := range list {
|
||||
res, _ := r.(map[string]any)
|
||||
id, _ := res["id"].(string)
|
||||
path, _ := res["path"].(string)
|
||||
switch {
|
||||
case id == "":
|
||||
case path != "":
|
||||
dirs[id] = path
|
||||
case res["type"] == "directory":
|
||||
dirs[id] = placedDir
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// resolveDir follows a leading ${dir:<id>} through the module's directories. It answers the path with the references
|
||||
// resolved, and whether it ends under a placed directory.
|
||||
func resolveDir(x string, dirs map[string]string) (string, bool) {
|
||||
for i := 0; i < 8; i++ {
|
||||
m := ofDir.FindStringSubmatch(x)
|
||||
if m == nil {
|
||||
return x, false
|
||||
}
|
||||
to, ok := dirs[m[1]]
|
||||
if !ok {
|
||||
return x, false
|
||||
}
|
||||
if to == placedDir {
|
||||
return x, true
|
||||
}
|
||||
x = to + strings.TrimPrefix(x, m[0])
|
||||
}
|
||||
return x, false
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
|
||||
@@ -37,6 +37,36 @@ func TestAnotherModuleNamingSSHIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's);
|
||||
// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused.
|
||||
func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "builder", `{"module":"builder","resources":[{"id":"state","type":"directory","place":"."},{"id":"ssh-dir","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`)
|
||||
manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "other" {
|
||||
t.Fatalf("refused %+v; want only other", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484 review: only a directory the mesh places is the module's own home. A ${dir:} that resolves to the
|
||||
// operator's home is judged by the path it resolves to.
|
||||
func TestADirResolvingToTheOperatorsHomeIsStillRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "escape", `{"module":"escape","resources":[{"id":"h","type":"directory","path":"${machine:account-home}","unknown":"module"},{"path":"${dir:h}/.ssh/config"}]}`)
|
||||
manifest(t, root, "placed", `{"module":"placed","resources":[{"id":"state","type":"directory","place":".","unknown":"program"},{"id":"s","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:s}/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "escape" {
|
||||
t.Fatalf("refused %+v; want only escape", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# brother — the Brother multifunction device: scan, printer state, admin pages
|
||||
|
||||
One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the
|
||||
network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`,
|
||||
`status`, `list`, `document`) and `printer` (verbs `status`, `settings`, `print`, `upload`; `restart` follows in a later round). It replaces the
|
||||
`scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue.
|
||||
|
||||
## The scanner seat
|
||||
|
||||
Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that
|
||||
speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF
|
||||
in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called:
|
||||
the module runs no process of its own, and it runs as the node's operator account.
|
||||
|
||||
## What a scan does
|
||||
|
||||
1. Asks the scanner for its glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole
|
||||
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once.
|
||||
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
|
||||
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
|
||||
3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
|
||||
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
|
||||
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
|
||||
4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to
|
||||
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
|
||||
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
|
||||
|
||||
A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why.
|
||||
|
||||
## Config
|
||||
|
||||
| key | meaning | default |
|
||||
|---|---|---|
|
||||
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
|
||||
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
|
||||
| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none |
|
||||
| `printer-certificate` | the SHA-256 fingerprint of the certificate the admin page may serve, `AA:BB:...`; several, separated by commas, when it serves more than one (required, trusted) | none |
|
||||
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
|
||||
| `color-mode` | `color` or `gray` | `color` |
|
||||
| `crop` | cut the document out of the lid's white | true |
|
||||
|
||||
The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes
|
||||
from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every scanner verb says which and
|
||||
writes and asks nothing; `printer` and `printer-certificate` are the printer verbs' to say.
|
||||
|
||||
The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code
|
||||
makes it (mode 0755) when it files the first scan.
|
||||
|
||||
## Tools
|
||||
|
||||
| verb (tool) | does |
|
||||
|---|---|
|
||||
| `scanner.scan` | asks the device process to scan, crop, keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` |
|
||||
| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 |
|
||||
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
|
||||
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
|
||||
|
||||
## The printer seat
|
||||
|
||||
| verb (tool) | does |
|
||||
|---|---|
|
||||
| `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 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
|
||||
|
||||
The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate
|
||||
whose SHA-256 fingerprint is one of those in `printer-certificate` and refuses any other, in the handshake, so no
|
||||
password is sent to it; the error names the served and the pinned fingerprints. Give each as 32 upper-case hex pairs
|
||||
joined by colons, several separated by commas. The printer serves two self-signed certificates for one name, an
|
||||
ECDSA one and an RSA one, and which it serves depends on the signature algorithms the client offers, so pin both
|
||||
(issue 1503): read them with `openssl s_client -connect <printer>:443 -sigalgs ECDSA+SHA256` and `-sigalgs
|
||||
RSA-PSS+SHA256`, each piped to `openssl x509 -noout -fingerprint -sha256`.
|
||||
|
||||
The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it
|
||||
at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the
|
||||
module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of
|
||||
mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names
|
||||
it for the device process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that
|
||||
no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret
|
||||
is not given, and the printer is not contacted.
|
||||
|
||||
### Documents in parts
|
||||
|
||||
The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module
|
||||
cuts and the most it accepts, as ADR 0468 clause 3 says),
|
||||
counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket
|
||||
(`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode
|
||||
0600), with the S3 client mesh-mail uses; a scan is kept once as `<sha256>.pdf` and an upload as `uploads/<id>.<ext>`.
|
||||
|
||||
### The device process
|
||||
|
||||
`brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a
|
||||
seat, and does all the device and bucket work: the scan (and `scan.added`, emitted by it with the object's key and no
|
||||
path, since filing in the scan folder is the tool's), the documents, the printing, and the admin pages. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and
|
||||
writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's
|
||||
title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the
|
||||
page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three
|
||||
deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can
|
||||
reach its own files; both are the choices that let its account read what it needs. Its health is its unit.
|
||||
|
||||
The login reads the form each time: the device generates the password field's name, so the code takes the name from
|
||||
the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is
|
||||
the login page again. The logout follows a logout link or form on the page; a page with none is said in the answer
|
||||
(`logout`), and the session is left to expire on the device.
|
||||
|
||||
## The event
|
||||
|
||||
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
||||
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
||||
`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
|
||||
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
|
||||
and `scanner.list` only reads.
|
||||
|
||||
## Tests
|
||||
|
||||
The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served
|
||||
by a test HTTP server, the captured IPP exchange (`testdata/ipp`) replayed, and the captured login page
|
||||
(`testdata/admin/login-page.html`) served by a test TLS server with its own certificate. The pages behind the login
|
||||
are synthetic (`testdata/admin/synthetic-*.html`) until round 2 captures the real ones. No test reaches the real
|
||||
device.
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
|
||||
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
|
||||
type Admin struct {
|
||||
Host string
|
||||
Password string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// loginStart is where a login starts: the root, which the printer answers with a redirect to the page it serves
|
||||
// its login form on (/home/status.html). Asking for a page behind the login instead is answered by a redirect
|
||||
// to an error page, and a loginurl naming a page this firmware lacks is a 404 after the login (issue 1514).
|
||||
const loginStart = "/"
|
||||
|
||||
// maxPage bounds a page of the admin site.
|
||||
const maxPage = 4 << 20
|
||||
|
||||
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
|
||||
// cookie jar for the session.
|
||||
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Admin{
|
||||
Host: host,
|
||||
Password: password,
|
||||
HTTP: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: pinnedTLS(fingerprint),
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
|
||||
|
||||
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
|
||||
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
if form != nil {
|
||||
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
} else {
|
||||
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := a.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
|
||||
}
|
||||
if len(buf) > maxPage {
|
||||
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil, ¬FoundError{Path: resp.Request.URL.Path}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
|
||||
}
|
||||
return buf, resp.Request.URL, nil
|
||||
}
|
||||
|
||||
// passwordForm is the form of a page that holds a password field, or nil.
|
||||
func passwordForm(p Page) (*Form, *Field) {
|
||||
for i := range p.Forms {
|
||||
for j := range p.Forms[i].Fields {
|
||||
if p.Forms[i].Fields[j].Type == "password" {
|
||||
return &p.Forms[i], &p.Forms[i].Fields[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func hasField(f *Form, name string) bool {
|
||||
for _, fl := range f.Fields {
|
||||
if fl.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Login asks for the printer's login page, reads its login form, and posts the form's own fields with the
|
||||
// password in the field the form names and the form's loginurl set to the default page, which every firmware has; the page asked for is a request of its own afterwards. A login
|
||||
// is failed when the answer is a login page again.
|
||||
func (a *Admin) Login(ctx context.Context) error {
|
||||
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginStart), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
|
||||
}
|
||||
form, pw := passwordForm(ParsePage(body))
|
||||
if form == nil {
|
||||
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", at.Path)
|
||||
}
|
||||
target, err := at.Parse(form.Action)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
|
||||
}
|
||||
if target.Scheme != "https" || target.Host != a.Host {
|
||||
return fmt.Errorf("the login form posts to %s, not to https on the printer %s: the password is not sent", target.Scheme+"://"+target.Host, a.Host)
|
||||
}
|
||||
vals := url.Values{}
|
||||
for _, f := range form.Fields {
|
||||
if f.Type == "hidden" {
|
||||
vals.Set(f.Name, f.Value)
|
||||
}
|
||||
}
|
||||
if _, ok := vals["loginurl"]; ok {
|
||||
vals.Set("loginurl", DefaultPage)
|
||||
}
|
||||
vals.Set(pw.Name, a.Password)
|
||||
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
|
||||
if err != nil {
|
||||
// The post may have opened a session before its answer failed: end it if there is one.
|
||||
if home, hat, herr := a.Get(context.Background(), DefaultPage); herr == nil {
|
||||
a.Logout(context.Background(), home, hat)
|
||||
}
|
||||
return fmt.Errorf("the login was not answered: %w", err)
|
||||
}
|
||||
// A login page again is a form with a password field and the loginurl field; a page that was asked for may
|
||||
// hold a password field of its own.
|
||||
if again, _ := passwordForm(ParsePage(answer)); again != nil && hasField(again, "loginurl") {
|
||||
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is a page of the admin site, after a login.
|
||||
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
|
||||
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
|
||||
}
|
||||
|
||||
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
|
||||
// none leaves the session to expire, and the answer says so.
|
||||
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
|
||||
p := ParsePage(page)
|
||||
isLogout := func(s string) bool {
|
||||
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
|
||||
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
|
||||
}
|
||||
for _, f := range p.Forms {
|
||||
if !isLogout(f.Action) {
|
||||
continue
|
||||
}
|
||||
target, err := at.Parse(f.Action)
|
||||
if err != nil || target.Host != a.Host {
|
||||
continue
|
||||
}
|
||||
vals := url.Values{}
|
||||
for _, fl := range f.Fields {
|
||||
if fl.Type == "hidden" {
|
||||
vals.Set(fl.Name, fl.Value)
|
||||
}
|
||||
}
|
||||
method := http.MethodPost
|
||||
var send url.Values = vals
|
||||
if f.Method == "get" {
|
||||
method, send = http.MethodGet, nil
|
||||
target.RawQuery = vals.Encode()
|
||||
}
|
||||
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
|
||||
return "logout failed: " + err.Error()
|
||||
}
|
||||
return "logged out"
|
||||
}
|
||||
for _, l := range p.Links {
|
||||
if !isLogout(l.Href) && !isLogout(l.Text) {
|
||||
continue
|
||||
}
|
||||
target, err := at.Parse(l.Href)
|
||||
if err != nil || target.Host != a.Host {
|
||||
continue
|
||||
}
|
||||
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
|
||||
return "logout failed: " + err.Error()
|
||||
}
|
||||
return "logged out"
|
||||
}
|
||||
return "no logout link or form on the page: the session is left to expire on the printer"
|
||||
}
|
||||
|
||||
// notFoundError is the printer's 404 for a page.
|
||||
type notFoundError struct{ Path string }
|
||||
|
||||
func (e *notFoundError) Error() string { return "the printer answered 404 Not Found for " + e.Path }
|
||||
@@ -0,0 +1,302 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one
|
||||
// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test
|
||||
// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/
|
||||
// synthetic-*.html): the real ones arrive in round 2 of issue 1453.
|
||||
type fakePrinter struct {
|
||||
*httptest.Server
|
||||
field string // the password field's name the login form gives
|
||||
password string
|
||||
mu sync.Mutex
|
||||
conns int // connections opened to it
|
||||
requests []string // method and path of every request
|
||||
posted []url.Values // every form body posted
|
||||
session string // the cookie value of the one logged-in session
|
||||
loggedIn map[string]bool // cookie values that are logged in
|
||||
pages map[string]string // path -> body behind the login
|
||||
}
|
||||
|
||||
func adminFixture(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "admin", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func newFakePrinter(t *testing.T, field, password string) *fakePrinter {
|
||||
t.Helper()
|
||||
f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}}
|
||||
login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field)
|
||||
// The login page the printer serves at /home/status.html carries that page as its loginurl (captured live
|
||||
// 2026-10-20, testdata/admin/live/home_status_html.body); the page it serves at /etc/loginerror.html carries
|
||||
// the page that was asked for.
|
||||
homeLogin := strings.ReplaceAll(login, `value="/general/status.html"`, `value="/home/status.html"`)
|
||||
synthetic := adminFixture(t, "synthetic-status.html")
|
||||
// This firmware has no /general/status.html behind the login (issue 1514): asking for it is a 404.
|
||||
f.pages["/home/status.html"] = synthetic
|
||||
f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html")
|
||||
authed := func(r *http.Request) bool {
|
||||
c, err := r.Cookie("session")
|
||||
return err == nil && f.loggedIn[c.Value]
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(login))
|
||||
})
|
||||
mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.Method != http.MethodPost {
|
||||
if authed(r) {
|
||||
w.Write([]byte(synthetic))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(homeLogin))
|
||||
return
|
||||
}
|
||||
r.ParseForm()
|
||||
f.posted = append(f.posted, r.PostForm)
|
||||
if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" {
|
||||
w.Write([]byte(login))
|
||||
return
|
||||
}
|
||||
f.session = "s" + field
|
||||
f.loggedIn[f.session] = true
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"})
|
||||
// Like Brother's form, a good login goes on to the page named in loginurl.
|
||||
http.Redirect(w, r, r.PostForm.Get("loginurl"), http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if c, err := r.Cookie("session"); err == nil {
|
||||
delete(f.loggedIn, c.Value)
|
||||
}
|
||||
w.Write([]byte(login))
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/home/status.html", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
// Captured: a page behind the login is answered by a redirect to the login page whether it exists or not.
|
||||
if !authed(r) {
|
||||
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
body, ok := f.pages[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
f.Server = httptest.NewUnstartedServer(mux)
|
||||
f.Config.ConnState = func(_ net.Conn, st http.ConnState) {
|
||||
if st == http.StateNew {
|
||||
f.mu.Lock()
|
||||
f.conns++
|
||||
f.mu.Unlock()
|
||||
}
|
||||
}
|
||||
f.StartTLS()
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") }
|
||||
|
||||
// print is the fingerprint of the certificate the server serves.
|
||||
func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) }
|
||||
|
||||
func (f *fakePrinter) connsNow() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.conns
|
||||
}
|
||||
|
||||
func (f *fakePrinter) loggedInNow() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.loggedIn)
|
||||
}
|
||||
|
||||
// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both
|
||||
// fingerprints, and the server saw no request at all, so no password was sent.
|
||||
func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "hunter2")
|
||||
other := strings.Repeat("AA:", 31) + "AA"
|
||||
a, err := NewAdmin(f.host(), other, "hunter2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = a.Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("a certificate that is not the pinned one was accepted")
|
||||
}
|
||||
for _, want := range []string{f.print(), other} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not name the fingerprint %s", err, want)
|
||||
}
|
||||
}
|
||||
if len(f.requests) != 0 || len(f.posted) != 0 {
|
||||
t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits.
|
||||
func TestThePinnedCertificateIsAccepted(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "hunter2")
|
||||
a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2")
|
||||
if err := a.Login(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes
|
||||
// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with.
|
||||
func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) {
|
||||
for _, name := range []string{"B1891", "Zq77x"} {
|
||||
f := newFakePrinter(t, name, "s3cret pass")
|
||||
a, _ := NewAdmin(f.host(), f.print(), "s3cret pass")
|
||||
ctx := context.Background()
|
||||
if err := a.Login(ctx); err != nil {
|
||||
t.Fatalf("field %s: %v", name, err)
|
||||
}
|
||||
if len(f.posted) != 1 {
|
||||
t.Fatalf("field %s: %d forms posted", name, len(f.posted))
|
||||
}
|
||||
if got := f.posted[0].Get(name); got != "s3cret pass" {
|
||||
t.Errorf("field %s: the password field holds %q", name, got)
|
||||
}
|
||||
if f.posted[0].Get("loginurl") != DefaultPage {
|
||||
t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl"))
|
||||
}
|
||||
if len(f.posted[0]) != 2 {
|
||||
t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0])
|
||||
}
|
||||
body, _, err := a.Get(ctx, "/general/other.html")
|
||||
if err != nil || !strings.Contains(string(body), "Synthetic settings") {
|
||||
t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4: a wrong password is a failed login, said so: the login page comes back.
|
||||
func TestAWrongPasswordIsAFailedLogin(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "right")
|
||||
a, _ := NewAdmin(f.host(), f.print(), "wrong")
|
||||
err := a.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "login failed") {
|
||||
t.Fatalf("error = %v, want a failed login", err)
|
||||
}
|
||||
if f.loggedInNow() != 0 {
|
||||
t.Errorf("a session was opened")
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: the logout link on a page ends the session; a page with none says so.
|
||||
func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
a, _ := NewAdmin(f.host(), f.print(), "pw")
|
||||
ctx := context.Background()
|
||||
if err := a.Login(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, at, err := a.Get(ctx, DefaultPage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.Logout(ctx, body, at); got != "logged out" {
|
||||
t.Errorf("logout = %q", got)
|
||||
}
|
||||
if f.loggedInNow() != 0 {
|
||||
t.Errorf("the session is still open")
|
||||
}
|
||||
if got := a.Logout(ctx, []byte("<html><body>nothing</body></html>"), at); !strings.Contains(got, "no logout") {
|
||||
t.Errorf("logout without a link = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: the module never sends the password to a host other than the printer, even if the form says so.
|
||||
func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) {
|
||||
// A login form whose action names another host.
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`<form method="post" action="https://elsewhere.example/x"><input type="password" name="p"/></form>`))
|
||||
}))
|
||||
srv.TLS = &tls.Config{}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "https://")
|
||||
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
|
||||
err := a.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "not sent") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1514 review: a page the printer does not have leaves no session open, and the error says the page is missing,
|
||||
// not that the login failed.
|
||||
func TestAMissingPageLeavesNoSessionOpenAndSaysSo(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
_, run := printerTools(t, f, "pw")
|
||||
_, err := run(map[string]any{"page": "/general/nope.html"})
|
||||
if err == nil || !strings.Contains(err.Error(), "the printer has no page /general/nope.html") {
|
||||
t.Fatalf("error = %v, want the printer has no page", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "login was not answered") {
|
||||
t.Errorf("a missing page is said to be a failed login: %v", err)
|
||||
}
|
||||
if f.loggedInNow() != 0 {
|
||||
t.Errorf("%d sessions are still open on the printer", f.loggedInNow())
|
||||
}
|
||||
}
|
||||
|
||||
// 1514 review: the password is posted to https on the printer's host only. A form whose action is plain http
|
||||
// on the same host, or a root that redirects to plain http, is refused and nothing is posted.
|
||||
func TestThePasswordIsNotPostedOverPlainHTTP(t *testing.T) {
|
||||
var posted int
|
||||
mux := http.NewServeMux()
|
||||
page := func(action string) string {
|
||||
return `<html><body><form method="post" action="` + action + `"><input type="password" name="B1891"/><input type="hidden" name="loginurl" value="/home/status.html"/></form></body></html>`
|
||||
}
|
||||
var host string
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posted++
|
||||
}
|
||||
w.Write([]byte(page("http://" + host + "/home/status.html")))
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
defer srv.Close()
|
||||
host = strings.TrimPrefix(srv.URL, "https://")
|
||||
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
|
||||
err := a.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "password is not sent") {
|
||||
t.Errorf("error = %v, want the plain http action refused", err)
|
||||
}
|
||||
if posted != 0 {
|
||||
t.Errorf("%d posts reached the server", posted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of
|
||||
// the platen's white and built into a one-page PDF. Keeping it in the bucket and saying scan.added are the
|
||||
// device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go).
|
||||
|
||||
func mm(pixels, dpi int) float64 {
|
||||
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10
|
||||
}
|
||||
|
||||
// Capture is a scanned, cropped page as a PDF, with what the event says of it.
|
||||
type Capture struct {
|
||||
PDF []byte
|
||||
SHA256 string
|
||||
WidthMM float64
|
||||
HeightMM float64
|
||||
Resolution int
|
||||
Cropped bool
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Capture scans the glass, crops and builds the PDF. Nothing is kept and nothing is said here; an error says why
|
||||
// the scanner gave no page. The resolution is the image's own, never the one asked (1440/AC2).
|
||||
func (s *Service) Capture(ctx context.Context) (Capture, error) {
|
||||
at := s.Now()
|
||||
scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode)
|
||||
if err != nil {
|
||||
return Capture{}, err
|
||||
}
|
||||
dpi, err := Density(scan)
|
||||
if err != nil {
|
||||
return Capture{}, err
|
||||
}
|
||||
cut, err := Crop(scan, s.Settings.Crop)
|
||||
if err != nil {
|
||||
return Capture{}, err
|
||||
}
|
||||
pdf, err := PDF(cut.JPEG, dpi)
|
||||
if err != nil {
|
||||
return Capture{}, err
|
||||
}
|
||||
return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(cut.Width, dpi), HeightMM: mm(cut.Height, dpi),
|
||||
Resolution: dpi, Cropped: cut.Cropped, At: at}, nil
|
||||
}
|
||||
|
||||
// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image,
|
||||
// and no path, since filing in a folder is the tool's, after the event.
|
||||
func (c Capture) Body(node, colorMode string) map[string]any {
|
||||
return map[string]any{
|
||||
"object": c.SHA256 + ".pdf",
|
||||
"node": node,
|
||||
"bytes": len(c.PDF),
|
||||
"sha256": c.SHA256,
|
||||
"width-mm": c.WidthMM,
|
||||
"height-mm": c.HeightMM,
|
||||
"resolution": c.Resolution,
|
||||
"color-mode": colorMode,
|
||||
"cropped": c.Cropped,
|
||||
"scanned-at": c.At.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
)
|
||||
|
||||
// Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner
|
||||
// and the lid's white everywhere else. The lid's level is measured where no document can be (the corner
|
||||
// farthest from the origin), a pixel is document when it is darker than the lid by a margin, and the rows and
|
||||
// columns holding enough document pixels give the box (ADR 0464).
|
||||
const (
|
||||
// lidBlock is the side of the corner block the lid's level is measured on.
|
||||
lidBlock = 300
|
||||
// darker is how much darker than the lid a pixel is document: paper on a white lid is a level or two
|
||||
// under it, an edge shadow far more.
|
||||
darker = 1
|
||||
// rowShare is the fraction of a row's (column's) length that must be document for the row (column) to
|
||||
// count: specks of dust stay under it.
|
||||
rowShare = 0.05
|
||||
// margin is the pixels kept around the box.
|
||||
margin = 6
|
||||
// wholeShare is the fraction of the platen a box may fill before it is no crop at all.
|
||||
wholeShare = 0.9
|
||||
// jpegQuality is the quality the cropped image is encoded with.
|
||||
jpegQuality = 90
|
||||
)
|
||||
|
||||
// gray is the pixel's level, 0 to 255.
|
||||
func grayAt(img image.Image, x, y int) int {
|
||||
switch m := img.(type) {
|
||||
case *image.YCbCr:
|
||||
return int(m.Y[m.YOffset(x, y)])
|
||||
case *image.Gray:
|
||||
return int(m.Pix[m.PixOffset(x, y)])
|
||||
}
|
||||
return int(color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y)
|
||||
}
|
||||
|
||||
// Box finds the document's box in the image, and whether there is one: false when no document is found or
|
||||
// when it fills (almost) the platen.
|
||||
func Box(img image.Image) (image.Rectangle, bool) {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w < 2*margin || h < 2*margin {
|
||||
return b, false
|
||||
}
|
||||
side := lidBlock
|
||||
if side > w/2 {
|
||||
side = w / 2
|
||||
}
|
||||
if side > h/2 {
|
||||
side = h / 2
|
||||
}
|
||||
sum := 0
|
||||
for y := b.Max.Y - side; y < b.Max.Y; y++ {
|
||||
for x := b.Max.X - side; x < b.Max.X; x++ {
|
||||
sum += grayAt(img, x, y)
|
||||
}
|
||||
}
|
||||
lid := int(float64(sum)/float64(side*side) + 0.5)
|
||||
limit := lid - darker
|
||||
|
||||
cols := make([]int, w)
|
||||
rows := make([]int, h)
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
if grayAt(img, x, y) < limit {
|
||||
cols[x-b.Min.X]++
|
||||
rows[y-b.Min.Y]++
|
||||
}
|
||||
}
|
||||
}
|
||||
x0, x1 := span(cols, int(rowShare*float64(h)))
|
||||
y0, y1 := span(rows, int(rowShare*float64(w)))
|
||||
if x0 < 0 || y0 < 0 {
|
||||
return b, false
|
||||
}
|
||||
box := image.Rect(max(0, x0-margin), max(0, y0-margin), min(w, x1+1+margin), min(h, y1+1+margin)).Add(b.Min)
|
||||
if float64(box.Dx()*box.Dy()) >= wholeShare*float64(w*h) {
|
||||
return b, false
|
||||
}
|
||||
return box, true
|
||||
}
|
||||
|
||||
// span is the first and last index whose count is over the floor, or -1 when none is.
|
||||
func span(counts []int, floor int) (int, int) {
|
||||
first, last := -1, -1
|
||||
for i, c := range counts {
|
||||
if c > floor {
|
||||
if first < 0 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
return first, last
|
||||
}
|
||||
|
||||
// Cropped is what cropping a scan gave.
|
||||
type Cropped struct {
|
||||
// JPEG is the image to file: the scanner's own bytes when kept whole, a re-encoded crop otherwise.
|
||||
JPEG []byte
|
||||
Width, Height int
|
||||
Cropped bool
|
||||
}
|
||||
|
||||
// Crop cuts the document out of a scanned JPEG when crop is set and a document is found; otherwise the whole
|
||||
// image is kept, its bytes untouched, and Cropped is false.
|
||||
func Crop(scan []byte, crop bool) (Cropped, error) {
|
||||
img, err := jpeg.Decode(bytes.NewReader(scan))
|
||||
if err != nil {
|
||||
return Cropped{}, fmt.Errorf("the scanned image cannot be read: %w", err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
whole := Cropped{JPEG: scan, Width: b.Dx(), Height: b.Dy()}
|
||||
if !crop {
|
||||
return whole, nil
|
||||
}
|
||||
box, found := Box(img)
|
||||
if !found {
|
||||
return whole, nil
|
||||
}
|
||||
sub, ok := img.(interface {
|
||||
SubImage(image.Rectangle) image.Image
|
||||
})
|
||||
if !ok {
|
||||
return whole, nil
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := jpeg.Encode(&out, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return Cropped{}, fmt.Errorf("the cropped image cannot be encoded: %w", err)
|
||||
}
|
||||
return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lidWhite is a w x h gray image of the lid's white, with a few specks of dust in one patch.
|
||||
func lidWhite(w, h int, seed int64) *image.Gray {
|
||||
g := image.NewGray(image.Rect(0, 0, w, h))
|
||||
draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src)
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
for i := 0; i < 40; i++ {
|
||||
g.SetGray(100+r.Intn(w/4), 100+r.Intn(h/4), color.Gray{120}) // all in one patch, so counting them would make a box
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func encode(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// 1440/AC3: the captured receipt, top-left of a 1696x2203 scan at 200 dpi.
|
||||
func TestCropFindsTheCapturedReceipt(t *testing.T) {
|
||||
c, err := Crop(fixture(t, "scan.jpg"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Cropped {
|
||||
t.Fatal("cropped = false for a scan with a receipt on it")
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(fixture(t, "scan.jpg")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, found := Box(img)
|
||||
if !found {
|
||||
t.Fatal("no box found")
|
||||
}
|
||||
if box.Min.X > 10 || box.Min.Y > 10 {
|
||||
t.Errorf("box starts at %v, want within 10 px of (0,0)", box.Min)
|
||||
}
|
||||
if box.Max.X < 630 || box.Max.X > 660 {
|
||||
t.Errorf("right edge %d, want 630..660", box.Max.X)
|
||||
}
|
||||
if box.Max.Y < 1730 || box.Max.Y > 1810 {
|
||||
t.Errorf("bottom %d, want 1730..1810", box.Max.Y)
|
||||
}
|
||||
if c.Width != box.Dx() || c.Height != box.Dy() {
|
||||
t.Errorf("cropped image is %dx%d, the box %dx%d", c.Width, c.Height, box.Dx(), box.Dy())
|
||||
}
|
||||
got, err := jpeg.DecodeConfig(bytes.NewReader(c.JPEG))
|
||||
if err != nil || got.Width != box.Dx() || got.Height != box.Dy() {
|
||||
t.Errorf("the cropped JPEG is %+v (%v), want %dx%d", got, err, box.Dx(), box.Dy())
|
||||
}
|
||||
// About 80-84 mm wide at 200 dpi.
|
||||
if mm := float64(c.Width) / 200 * 25.4; mm < 80 || mm > 84 {
|
||||
t.Errorf("%.1f mm wide, want 80-84", mm)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: a plain lid with specks has no document.
|
||||
func TestCropKeepsAPlainLidWhole(t *testing.T) {
|
||||
scan := encode(t, lidWhite(800, 1100, 1))
|
||||
c, err := Crop(scan, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 800 || c.Height != 1100 || !bytes.Equal(c.JPEG, scan) {
|
||||
t.Fatalf("a plain lid was cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: a document that fills the platen is no crop either.
|
||||
func TestCropKeepsAFullPlatenDocumentWhole(t *testing.T) {
|
||||
g := lidWhite(800, 1100, 2)
|
||||
draw.Draw(g, image.Rect(0, 0, 790, 1090), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
|
||||
scan := encode(t, g)
|
||||
c, err := Crop(scan, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 800 || c.Height != 1100 {
|
||||
t.Fatalf("a document filling the platen was cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: crop=false keeps the captured scan whole, though it has a document.
|
||||
func TestCropOffKeepsTheWholeImage(t *testing.T) {
|
||||
scan := fixture(t, "scan.jpg")
|
||||
c, err := Crop(scan, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 1696 || c.Height != 2203 || !bytes.Equal(c.JPEG, scan) {
|
||||
t.Fatalf("crop=false cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC3: a document away from the origin is found where it is, not assumed in the corner.
|
||||
func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) {
|
||||
g := lidWhite(800, 1100, 3)
|
||||
draw.Draw(g, image.Rect(200, 300, 500, 700), &image.Uniform{color.Gray{240}}, image.Point{}, draw.Src)
|
||||
img, err := jpeg.Decode(bytes.NewReader(encode(t, g)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, found := Box(img)
|
||||
if !found {
|
||||
t.Fatal("no box")
|
||||
}
|
||||
if box.Min.X < 190 || box.Min.X > 205 || box.Max.X < 495 || box.Max.X > 510 || box.Min.Y < 290 || box.Min.Y > 305 || box.Max.Y < 695 || box.Max.Y > 710 {
|
||||
t.Fatalf("box = %v, want about (200,300)-(500,700)", box)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the device process
|
||||
// reads it, as its own account, brother-device. The build seat runs every test under one account, so no
|
||||
// permission test can show the split; this is the file-level proxy mesh-mail's secret_boundary_test.go uses
|
||||
// instead: an AST check that the tools' files import no password-reading package and name no password file.
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const secretPackage = "brother/cmd/brother/internal/secret"
|
||||
|
||||
// toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings'
|
||||
// ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go.
|
||||
var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go"}
|
||||
|
||||
// 1453: none of the tools' files imports the password-reading package or names the password's file.
|
||||
func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) {
|
||||
for _, file := range toolFiles {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
for _, imp := range f.Imports {
|
||||
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
|
||||
t.Errorf("%s imports %s, which only the device process may (ADR 0436 §12)", file, secretPackage)
|
||||
}
|
||||
}
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{`Getenv("BROTHER_ADMIN_PASSWORD")`, `Getenv("BROTHER_STORE")`, "ReadStoreFacts", "NewS3", "StoreFacts"} {
|
||||
if strings.Contains(string(src), bad) {
|
||||
t.Errorf("%s names %s: the password's and the bucket key's files are the device process's alone (ADR 0468 clause 1)", file, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: the device process does read it through the package, and the package is the one place a password file is
|
||||
// opened.
|
||||
func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "deviceproc.go", nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, imp := range f.Imports {
|
||||
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("deviceproc.go does not import %s", secretPackage)
|
||||
}
|
||||
for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go", "objects.go", "capture.go") {
|
||||
src, _ := os.ReadFile(file)
|
||||
if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" && file != "objects.go" {
|
||||
t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The device process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's
|
||||
// sender_integration_test.go, ADR 0436 §7): a JetStream server of the test's own, the work-queue stream
|
||||
// device-asks and its consumer, the state bucket device-answers, and a fake printer. An ask published on the
|
||||
// stream is answered in the state under its id and acknowledged. Compiled by the merge gate's
|
||||
// `go vet -tags integration`; never run on this machine.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"brother/cmd/brother/internal/secret"
|
||||
)
|
||||
|
||||
// printerAskedSubject is the wire subject the controller composes for the stream's one event:
|
||||
// "mesh.stream." + module + ".event." + event (mesh-controller internal/broker/modstreams.go).
|
||||
const printerAskedSubject = "mesh.stream.brother.event." + EventAsked
|
||||
|
||||
func newTestBus(t *testing.T) string {
|
||||
t.Helper()
|
||||
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
|
||||
NoLog: true, NoSigs: true}
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus for this test could not be made: %v", err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
s.Shutdown()
|
||||
t.Fatal("a bus for this test did not come up within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
return s.ClientURL()
|
||||
}
|
||||
|
||||
// 1453: an ask on device-asks is answered under its id in device-answers by the device process, over the
|
||||
// printer's pinned HTTPS and the login, and acknowledged; stopping it leaves nothing in hand.
|
||||
func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
|
||||
url := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: AsksStream, Subjects: []string{printerAskedSubject},
|
||||
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
|
||||
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
|
||||
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: account.Module + "_" + AnswersState, TTL: 10 * time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
dir := t.TempDir()
|
||||
settings := filepath.Join(dir, "settings.json")
|
||||
os.WriteFile(settings, []byte(`{"printer":"`+f.host()+`","printer-certificate":"`+f.print()+`"}`), 0o600)
|
||||
pwFile := filepath.Join(dir, "admin-password.secret")
|
||||
os.WriteFile(pwFile, []byte("pw\n"), 0o600)
|
||||
w := &deviceWorker{
|
||||
Settings: func() (Settings, error) { return LoadSettings(settings) },
|
||||
Password: func() (string, error) { return secret.Read(pwFile) },
|
||||
Now: time.Now,
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runDevice(runCtx, account, w) }()
|
||||
|
||||
body, _ := json.Marshal(Ask{ID: "ask-1", Op: OpSettings, Page: "/general/other.html", At: time.Now()})
|
||||
if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var answer string
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := kv.Get(ctx, "ask-1"); err == nil {
|
||||
answer = string(e.Value())
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(answer, "Synthetic settings") {
|
||||
t.Fatalf("the answer under ask-1 is %q", answer)
|
||||
}
|
||||
if strings.Contains(answer, "shouldnotshow") {
|
||||
t.Errorf("a password field's value is in the answer")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("runDevice returned nil, want ctx.Err() once its context was cancelled")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runDevice did not stop once its context was cancelled")
|
||||
}
|
||||
cons, err := js.Consumer(ctx, AsksStream, account.QueueConsumerName())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := cons.Info(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.NumPending != 0 || info.NumAckPending != 0 {
|
||||
t.Errorf("the ask was not acknowledged: %d pending, %d awaiting an acknowledgement", info.NumPending, info.NumAckPending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package main
|
||||
|
||||
// mesh brother device (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the device process, a process
|
||||
// with a bus account of its own (brother-device), never a seat. It alone of the module's parts reads the admin
|
||||
// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream device-asks
|
||||
// and read its answer in the state device-answers. The admin password is never in an answer, an event or a log.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"brother/cmd/brother/internal/secret"
|
||||
)
|
||||
|
||||
// Answers is the state the worker writes an answer in, once.
|
||||
type Answers interface {
|
||||
Create(key string, value any) (uint64, error)
|
||||
}
|
||||
|
||||
// action is what is done to the queued message.
|
||||
type action int
|
||||
|
||||
const (
|
||||
actionAck action = iota
|
||||
actionRetry
|
||||
actionTerm
|
||||
// actionSettled says the message was acknowledged already, before the work began: what is not safe to do twice
|
||||
// (a scan, a print) is acknowledged first, so a slow one is not delivered again after the bus's wait.
|
||||
actionSettled
|
||||
)
|
||||
|
||||
// pendingUpload is the parts of an upload that have come.
|
||||
type pendingUpload struct {
|
||||
parts map[int][]byte
|
||||
n int
|
||||
sha string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// pendingFor is how long an incomplete upload's parts are kept.
|
||||
const pendingFor = 15 * time.Minute
|
||||
|
||||
// deviceWorker answers the asks: it alone talks to the device (the scanner, the printer's pages and IPP) and to
|
||||
// the bucket. Settings and the password are read at each ask, so a changed config value or a password given later
|
||||
// is the next ask's.
|
||||
type deviceWorker struct {
|
||||
Settings func() (Settings, error)
|
||||
Password func() (string, error)
|
||||
Objects func() (Objects, error)
|
||||
// Emit says scan.added on the bus under a message id.
|
||||
Emit func(body map[string]any, id string) error
|
||||
Answers Answers
|
||||
Node string
|
||||
// Capture scans; nil is the device's own (a test puts a stand-in).
|
||||
Capture func(ctx context.Context) (Capture, error)
|
||||
// answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger).
|
||||
answerPart int
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingUpload
|
||||
}
|
||||
|
||||
func (w *deviceWorker) logf(format string, a ...any) {
|
||||
if w.Logf != nil {
|
||||
w.Logf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
// tooLargeCode is the bus's error code for a message over the maximum allowed (the state refuses a value so).
|
||||
const tooLargeCode = 10054
|
||||
|
||||
// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait for
|
||||
// is acknowledged and the device not touched; every other outcome, a failure included, is written as the answer
|
||||
// under the ask's id and the message acknowledged; an answer the state refuses as too large is
|
||||
// replaced by a small error answer and the message terminated; an answer that could not be written for another
|
||||
// reason (the store away, a timeout) is redelivered (the bus's limit ends that). early acknowledges the message; it is called before a scan or a print, which are not
|
||||
// done twice.
|
||||
func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration) {
|
||||
var ask Ask
|
||||
if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" {
|
||||
w.logf("[brother] device: a queued ask could not be read: %v", err)
|
||||
return actionTerm, 0
|
||||
}
|
||||
if age := w.Now().Sub(ask.At); age > StaleAfter {
|
||||
w.logf("[brother] device: ask %s is %s old; nobody waits for it, the device is not touched", ask.ID, age.Round(time.Second))
|
||||
return actionAck, 0
|
||||
}
|
||||
settled := false
|
||||
if (ask.Op == OpScan || ask.Op == OpPrint) && early != nil {
|
||||
early()
|
||||
settled = true
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
var answer map[string]any
|
||||
if got, err := w.run(ctx, ask); err != nil {
|
||||
answer = map[string]any{"error": err.Error()}
|
||||
} else {
|
||||
answer = got
|
||||
}
|
||||
if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
||||
w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err)
|
||||
var api *jetstream.APIError
|
||||
if !errors.As(err, &api) || api.ErrorCode != tooLargeCode {
|
||||
// The store is away, timed out or cut: the answer may be written next time (a scan is settled already).
|
||||
if settled {
|
||||
return actionSettled, 0
|
||||
}
|
||||
return actionRetry, 5 * time.Second
|
||||
}
|
||||
// The ask ends here: a refused value would be refused again.
|
||||
// Say the failure as a small error answer, so the tool's wait ends with the reason (issue 1535).
|
||||
msg := fmt.Sprintf("the answer to this ask could not be written in the state %s: %v", AnswersState, err)
|
||||
if _, err2 := w.Answers.Create(ask.ID, map[string]any{"error": msg}); err2 != nil && !errors.Is(err2, stdio.ErrChanged) {
|
||||
w.logf("[brother] device: the failure of %s could not be written either: %v", ask.ID, err2)
|
||||
}
|
||||
if settled {
|
||||
return actionSettled, 0
|
||||
}
|
||||
return actionTerm, 0
|
||||
}
|
||||
if settled {
|
||||
return actionSettled, 0
|
||||
}
|
||||
return actionAck, 0
|
||||
}
|
||||
|
||||
// run is the operation an ask names.
|
||||
func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) {
|
||||
switch ask.Op {
|
||||
case OpSettings:
|
||||
return w.read(ctx, ask.Page, ask.Links, ask.Hidden)
|
||||
case OpScan:
|
||||
return w.scan(ctx)
|
||||
case OpDocument:
|
||||
return w.document(ctx, ask)
|
||||
case OpUpload:
|
||||
return w.upload(ctx, ask)
|
||||
case OpPrint:
|
||||
return w.print(ctx, ask)
|
||||
}
|
||||
return nil, fmt.Errorf("the ask %s names an operation, %q, the device process does not do", ask.ID, ask.Op)
|
||||
}
|
||||
|
||||
// scan scans the glass, crops, builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with
|
||||
// the object's key (ADR 0468 clause 2), in that order: nothing is said of a scan that is not kept, and nothing is
|
||||
// kept nor said when the scanner gives no page (1440/AC6). The answer is the event's body with key, size and the
|
||||
// number of parts.
|
||||
func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) {
|
||||
set, err := w.Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set.Scanner == "" {
|
||||
return nil, unsetError("scanner")
|
||||
}
|
||||
objects, err := w.Objects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := &Service{Settings: set, Node: w.Node, Now: w.Now}
|
||||
capture := svc.Capture
|
||||
if w.Capture != nil {
|
||||
capture = w.Capture
|
||||
}
|
||||
c, err := capture(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := c.Body(w.Node, set.ColorMode)
|
||||
key := body["object"].(string)
|
||||
if err := Store(ctx, objects, key, "application/pdf", c.PDF); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Emit(body, c.SHA256); err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but %s could not be said on the bus: %w", key, EventAdded, err)
|
||||
}
|
||||
answer := map[string]any{"key": key, "size": len(c.PDF), "parts": answerPartCount(len(c.PDF))}
|
||||
for k, v := range body {
|
||||
answer[k] = v
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// document is one part of a scan kept in the bucket.
|
||||
func (w *deviceWorker) document(ctx context.Context, ask Ask) (map[string]any, error) {
|
||||
if err := checkDocument(ask); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects, err := w.Objects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := objects.Get(ctx, ask.Object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
each := w.answerPart
|
||||
if each <= 0 {
|
||||
each = AnswerPartSize
|
||||
}
|
||||
part, n, err := cutPartOf(data, ask.Part, each)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"part": ask.Part, "parts": n, "size": len(data), "sha256": sha256Hex(data),
|
||||
"data": base64.StdEncoding.EncodeToString(part)}, nil
|
||||
}
|
||||
|
||||
// upload keeps a part, and once all have come joins them, checks the SHA-256 and the format and stores the document
|
||||
// as uploads/<upload>.<ext>. A wrong SHA-256 or a document that is neither PDF nor JPEG stores nothing, and the
|
||||
// parts are dropped; a part out of range is refused.
|
||||
func (w *deviceWorker) upload(ctx context.Context, ask Ask) (map[string]any, error) {
|
||||
if err := checkUpload(ask); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, _ := base64.StdEncoding.DecodeString(ask.Data)
|
||||
w.mu.Lock()
|
||||
if w.pending == nil {
|
||||
w.pending = map[string]*pendingUpload{}
|
||||
}
|
||||
for id, p := range w.pending {
|
||||
if w.Now().Sub(p.at) > pendingFor {
|
||||
delete(w.pending, id)
|
||||
}
|
||||
}
|
||||
p := w.pending[ask.Upload]
|
||||
if p == nil {
|
||||
p = &pendingUpload{parts: map[int][]byte{}, n: ask.Parts, sha: ask.SHA256}
|
||||
w.pending[ask.Upload] = p
|
||||
}
|
||||
if p.n != ask.Parts || p.sha != ask.SHA256 {
|
||||
w.mu.Unlock()
|
||||
return nil, fmt.Errorf("upload %s was begun with %d parts and SHA-256 %s: its parts all say the same", ask.Upload, p.n, p.sha)
|
||||
}
|
||||
p.parts[ask.Part] = raw
|
||||
p.at = w.Now()
|
||||
if len(p.parts) < p.n {
|
||||
got := len(p.parts)
|
||||
w.mu.Unlock()
|
||||
return map[string]any{"upload": ask.Upload, "complete": false, "received": got, "parts": ask.Parts}, nil
|
||||
}
|
||||
delete(w.pending, ask.Upload)
|
||||
w.mu.Unlock()
|
||||
|
||||
ordered := make([][]byte, p.n)
|
||||
for i := range ordered {
|
||||
ordered[i] = p.parts[i+1]
|
||||
}
|
||||
doc, err := joinParts(ordered, p.sha)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
|
||||
}
|
||||
format, ext, err := SniffFormat(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
|
||||
}
|
||||
objects, err := w.Objects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := "uploads/" + ask.Upload + "." + ext
|
||||
if err := objects.Put(ctx, key, format, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"upload": ask.Upload, "complete": true, "object": key, "size": len(doc), "sha256": p.sha}, nil
|
||||
}
|
||||
|
||||
// print fetches a scan or a completed upload from the bucket and sends it to the printer as an IPP Print-Job in
|
||||
// its own format, and answers the job's id and state. The printer's host is its IPP address; nothing is fetched
|
||||
// or sent before the ask is checked.
|
||||
func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, error) {
|
||||
if err := checkPrint(&ask); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, err := w.Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
}
|
||||
objects, err := w.Objects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := ask.Object
|
||||
if key == "" {
|
||||
for _, ext := range []string{"pdf", "jpg"} {
|
||||
k := "uploads/" + ask.Upload + "." + ext
|
||||
if has, err := objects.Has(ctx, k); err != nil {
|
||||
return nil, err
|
||||
} else if has {
|
||||
key = k
|
||||
break
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload)
|
||||
}
|
||||
}
|
||||
doc, err := objects.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
format, _, err := SniffFormat(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode,
|
||||
"job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}, nil
|
||||
}
|
||||
|
||||
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
|
||||
// the only things it sends, and a password field's value is never in the answer. The printer is not contacted
|
||||
// before the settings and the password are known to be given (1453/AC4).
|
||||
func (w *deviceWorker) read(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
|
||||
if page == "" {
|
||||
page = DefaultPage
|
||||
}
|
||||
if !validPage(page) {
|
||||
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
|
||||
}
|
||||
set, err := w.Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := set.MissingPrinter(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pw, err := w.Password()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := NewAdmin(set.Printer, set.PrinterCertificate, pw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.Login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Log out with a context of its own: the ask's may be what ended this.
|
||||
lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
body, at, err := a.Get(ctx, page)
|
||||
if err != nil {
|
||||
if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil {
|
||||
a.Logout(lctx, home, hat)
|
||||
}
|
||||
var nf *notFoundError
|
||||
if errors.As(err, &nf) {
|
||||
return nil, fmt.Errorf("the printer has no page %s", page)
|
||||
}
|
||||
return nil, fmt.Errorf("page %s cannot be read: %w", page, err)
|
||||
}
|
||||
logout := a.Logout(lctx, body, at)
|
||||
p := ParsePage(body)
|
||||
out := map[string]any{
|
||||
"page": page,
|
||||
"title": p.Title,
|
||||
"forms": nonNilForms(withoutHidden(p.Forms, hidden)),
|
||||
"logout": logout,
|
||||
}
|
||||
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 {
|
||||
if l == nil {
|
||||
return []Link{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func nonNilForms(f []Form) []Form {
|
||||
if f == nil {
|
||||
return []Form{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// device is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped.
|
||||
// It refuses to start naming what is missing when the account cannot be read, and never logs a password.
|
||||
func device() int {
|
||||
settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER")
|
||||
store := os.Getenv("BROTHER_STORE")
|
||||
if brokerFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file")
|
||||
return 2
|
||||
}
|
||||
account, err := process.ReadAccount(brokerFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[brother] device: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
w := &deviceWorker{
|
||||
Settings: func() (Settings, error) { return LoadSettings(settings) },
|
||||
Password: func() (string, error) { return secret.Read(passwordFile) },
|
||||
Objects: func() (Objects, error) {
|
||||
facts, err := ReadStoreFacts(store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewS3(facts), nil
|
||||
},
|
||||
Now: time.Now,
|
||||
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[brother] device is consuming %s as %s\n", AsksStream, account.User)
|
||||
err = runDevice(ctx, account, w)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintf(os.Stderr, "[brother] device stopped: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// runDevice connects as the device process's own account and consumes device-asks until ctx ends, answering each
|
||||
// ask in the state device-answers on that connection and then settling the message as Handle says. Apart from
|
||||
// device so a test can drive it over a bus of its own.
|
||||
func runDevice(ctx context.Context, account process.Account, w *deviceWorker) error {
|
||||
conn, err := process.Connect(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bus cannot be reached: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
w.Answers = conn.State(AnswersState)
|
||||
if w.Node == "" {
|
||||
w.Node = account.Node
|
||||
}
|
||||
w.Emit = func(body map[string]any, id string) error {
|
||||
return conn.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id})
|
||||
}
|
||||
return conn.Consume(ctx, AsksStream, func(msg *process.Message) {
|
||||
var settleErr error
|
||||
act, delay := w.Handle(msg.Body, func() { settleErr = msg.Ack() })
|
||||
if settleErr != nil {
|
||||
w.logf("[brother] device: acknowledging %s early: %v", msg.Subject, settleErr)
|
||||
}
|
||||
var aerr error
|
||||
switch act {
|
||||
case actionAck:
|
||||
aerr = msg.Ack()
|
||||
case actionRetry:
|
||||
aerr = msg.NakWithDelay(delay)
|
||||
case actionTerm:
|
||||
aerr = msg.Term()
|
||||
}
|
||||
if aerr != nil {
|
||||
w.logf("[brother] device: settling %s: %v", msg.Subject, aerr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pdfOf is a document that is a PDF to the sniff and size bytes long, its bytes varying so that a part out of
|
||||
// place changes the join.
|
||||
func pdfOf(size int) []byte {
|
||||
b := make([]byte, size)
|
||||
copy(b, "%PDF-1.4\n")
|
||||
for i := 9; i < size; i++ {
|
||||
b[i] = byte(i*7 + i/251)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 1453/AC10: a document is cut into parts of at most PartSize, under the limit, and the parts joined in
|
||||
// order are the document, with the SHA-256 checked; a wrong SHA-256 or a part missing is refused; a part out of
|
||||
// range is refused naming the range.
|
||||
func TestADocumentIsCutIntoPartsAndJoinedWithItsSha256Checked(t *testing.T) {
|
||||
doc := pdfOf(2*AnswerPartSize + 1234)
|
||||
n := answerPartCount(len(doc))
|
||||
if n != 3 {
|
||||
t.Fatalf("%d parts, want 3", n)
|
||||
}
|
||||
var parts [][]byte
|
||||
for i := 1; i <= n; i++ {
|
||||
p, total, err := cutPart(doc, i)
|
||||
if err != nil || total != n {
|
||||
t.Fatalf("part %d: %v (of %d)", i, err, total)
|
||||
}
|
||||
if len(p) > AnswerPartSize {
|
||||
t.Errorf("part %d is %d bytes, over %d", i, len(p), AnswerPartSize)
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
got, err := joinParts(parts, sha256Hex(doc))
|
||||
if err != nil || !bytes.Equal(got, doc) {
|
||||
t.Fatalf("the join is not the document: %v", err)
|
||||
}
|
||||
if _, err := joinParts(parts, sha256Hex([]byte("other"))); err == nil || !strings.Contains(err.Error(), sha256Hex(doc)) {
|
||||
t.Errorf("a wrong SHA-256 was accepted or the error does not name the joined one: %v", err)
|
||||
}
|
||||
if _, err := joinParts(parts[:2], sha256Hex(doc)); err == nil {
|
||||
t.Errorf("a join with a part missing passed its SHA-256")
|
||||
}
|
||||
for _, bad := range []int{0, -1, n + 1} {
|
||||
if _, _, err := cutPart(doc, bad); err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Errorf("part %d: error = %v", bad, err)
|
||||
}
|
||||
}
|
||||
if partCount(0) != 1 {
|
||||
t.Errorf("an empty document has one part")
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC10: scanner.document gives each part of a kept scan with the part count, the size and the SHA-256, each
|
||||
// answer within the bus's 1 MiB; the parts joined are the scan. A part out of range, or a key that is no scan's, is
|
||||
// refused, the first before anything is queued.
|
||||
func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
doc := pdfOf(AnswerPartSize + 5000)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
var joined []byte
|
||||
for i := 1; i <= 2; i++ {
|
||||
got, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(i)})
|
||||
if err != nil {
|
||||
t.Fatalf("part %d: %v", i, err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["parts"] != float64(2) || m["size"] != float64(len(doc)) || m["sha256"] != sha256Hex(doc) || m["part"] != float64(i) {
|
||||
t.Errorf("part %d answer = %v", i, m)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(m["data"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined = append(joined, raw...)
|
||||
}
|
||||
if !bytes.Equal(joined, doc) {
|
||||
t.Errorf("the parts fetched are not the scan")
|
||||
}
|
||||
before := r.bus.asks()
|
||||
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(0)}); err == nil {
|
||||
t.Errorf("part 0 was asked for")
|
||||
}
|
||||
if r.bus.asks() != before {
|
||||
t.Errorf("an ask was queued for a part the tool could refuse")
|
||||
}
|
||||
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(3)}); err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Errorf("part 3: error = %v", err)
|
||||
}
|
||||
for _, k := range []string{"../etc/passwd", "uploads/x.pdf", strings.ToUpper(key)} {
|
||||
if _, err := r.call(t, "scanner.document", map[string]any{"object": k, "part": float64(1)}); err == nil || !strings.Contains(err.Error(), "not a scan's key") {
|
||||
t.Errorf("object %q: error = %v", k, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.call(t, "scanner.document", map[string]any{"object": sha256Hex([]byte("none")) + ".pdf", "part": float64(1)}); err == nil {
|
||||
t.Errorf("a scan that is not kept was given")
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed.
|
||||
func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
// Everything a scan is made of but its size is the real one; the stored object is what the parts carry, so a
|
||||
// bigger object stands for a bigger scan: replace what the device keeps with a three-part document.
|
||||
big := pdfOf(2*AnswerPartSize + 99)
|
||||
bigKey := sha256Hex(big) + ".pdf"
|
||||
r.objects.Put(t.Context(), bigKey, "application/pdf", big)
|
||||
got := make([][]byte, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
p, err := r.worker.document(t.Context(), Ask{Op: OpDocument, Object: bigKey, Part: i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := base64.StdEncoding.DecodeString(p["data"].(string))
|
||||
got = append(got, raw)
|
||||
}
|
||||
if joined, err := joinParts(got, sha256Hex(big)); err != nil || !bytes.Equal(joined, big) {
|
||||
t.Fatalf("three parts do not join: %v", err)
|
||||
}
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["path"] == nil {
|
||||
t.Errorf("no path: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func upload(t *testing.T, r *printerRig, id string, doc []byte, sha string, part, parts int, piece []byte) (any, error) {
|
||||
t.Helper()
|
||||
return r.call(t, "printer.upload", map[string]any{"upload": id, "part": float64(part), "parts": float64(parts), "sha256": sha,
|
||||
"data": base64.StdEncoding.EncodeToString(piece)})
|
||||
}
|
||||
|
||||
// 1453/AC11: an upload in parts, in any order, is stored as uploads/<id>.pdf once all have come and the joined
|
||||
// SHA-256 matches; until then it says how many came and stores nothing.
|
||||
func TestAnUploadIsStoredOnceAllItsPartsCameAndTheSha256Matches(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
doc := pdfOf(PartSize + 700)
|
||||
sum := sha256Hex(doc)
|
||||
p1, _, _ := cutPartOf(doc, 1, PartSize)
|
||||
p2, _, _ := cutPartOf(doc, 2, PartSize)
|
||||
got, err := upload(t, r, "up-1", doc, sum, 2, 2, p2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := got.(map[string]any); m["complete"] != false || m["received"] != float64(1) {
|
||||
t.Errorf("after one part: %v", m)
|
||||
}
|
||||
if len(r.objects.keys()) != 0 {
|
||||
t.Errorf("a document was stored before its parts all came: %v", r.objects.keys())
|
||||
}
|
||||
got, err = upload(t, r, "up-1", doc, sum, 1, 2, p1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["complete"] != true || m["object"] != "uploads/up-1.pdf" || m["sha256"] != sum || m["size"] != float64(len(doc)) {
|
||||
t.Errorf("after the last part: %v", m)
|
||||
}
|
||||
if kept, err := r.objects.Get(t.Context(), "uploads/up-1.pdf"); err != nil || !bytes.Equal(kept, doc) {
|
||||
t.Errorf("the stored document is not the upload (%v)", err)
|
||||
}
|
||||
// A JPEG is stored with its own extension.
|
||||
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{7}, 100)...)
|
||||
if _, err := upload(t, r, "pic", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.objects.Get(t.Context(), "uploads/pic.jpg"); err != nil {
|
||||
t.Errorf("the JPEG is not stored as uploads/pic.jpg: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC11: a wrong SHA-256 on the joined document stores nothing and is said; a document that is neither PDF nor
|
||||
// JPEG stores nothing; a part out of range, a part larger than the limit, a part that is not base64 and parts that
|
||||
// disagree are refused; the upload can then be sent again whole.
|
||||
func TestAnUploadWithAWrongSha256OrAnOutOfRangePartStoresNothing(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
doc := pdfOf(300)
|
||||
wrong := sha256Hex([]byte("not it"))
|
||||
if _, err := upload(t, r, "bad", doc, wrong, 1, 1, doc); err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), sha256Hex(doc)) {
|
||||
t.Errorf("a wrong SHA-256: error = %v", err)
|
||||
}
|
||||
if len(r.objects.keys()) != 0 || r.objects.puts != 0 {
|
||||
t.Errorf("a document with a wrong SHA-256 was stored: %v", r.objects.keys())
|
||||
}
|
||||
text := []byte("plain text, no PDF")
|
||||
if _, err := upload(t, r, "txt", text, sha256Hex(text), 1, 1, text); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
|
||||
t.Errorf("a text upload: error = %v", err)
|
||||
}
|
||||
for _, c := range []struct{ part, parts int }{{0, 1}, {2, 1}, {1, 0}, {1, MostParts + 1}} {
|
||||
if _, err := upload(t, r, "range", doc, sha256Hex(doc), c.part, c.parts, doc); err == nil {
|
||||
t.Errorf("part %d of %d was taken", c.part, c.parts)
|
||||
}
|
||||
}
|
||||
if _, err := upload(t, r, "range", doc, sha256Hex(doc), 2, 1, doc); err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Errorf("part 2 of 1: error = %v", err)
|
||||
}
|
||||
if _, err := r.call(t, "printer.upload", map[string]any{"upload": "x", "part": float64(1), "parts": float64(1), "sha256": sha256Hex(doc), "data": "!!"}); err == nil {
|
||||
t.Errorf("a part that is not base64 was taken")
|
||||
}
|
||||
if _, err := upload(t, r, "big", doc, sha256Hex(doc), 1, 1, make([]byte, MaxPart+1)); err == nil || !strings.Contains(err.Error(), "at most") {
|
||||
t.Errorf("a part over the limit: error = %v", err)
|
||||
}
|
||||
if r.bus.asks() != 2 {
|
||||
// Only the two uploads that were complete in form reached the device process; every refusal above them
|
||||
// was the tool's.
|
||||
t.Errorf("%d asks queued, want the 2 whose form was right", r.bus.asks())
|
||||
}
|
||||
// Parts that disagree about the whole.
|
||||
p := pdfOf(PartSize + 10)
|
||||
p1, _, _ := cutPartOf(p, 1, PartSize)
|
||||
if _, err := upload(t, r, "mix", p, sha256Hex(p), 1, 2, p1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := upload(t, r, "mix", p, sha256Hex(p), 2, 3, p1); err == nil || !strings.Contains(err.Error(), "all say the same") {
|
||||
t.Errorf("parts that disagree: error = %v", err)
|
||||
}
|
||||
if len(r.objects.keys()) != 0 {
|
||||
t.Errorf("objects stored: %v", r.objects.keys())
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC11: an incomplete upload's parts are dropped after fifteen minutes.
|
||||
func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
doc := pdfOf(PartSize + 10)
|
||||
p1, _, _ := cutPartOf(doc, 1, PartSize)
|
||||
p2, _, _ := cutPartOf(doc, 2, PartSize)
|
||||
now := fixedNow
|
||||
r.worker.Now = func() time.Time { return now }
|
||||
if _, err := upload(t, r, "late", doc, sha256Hex(doc), 1, 2, p1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(pendingFor + time.Minute)
|
||||
// Asked of the worker directly: the tool's own clock is the fixed one, and an ask this old would be stale.
|
||||
got, err := r.worker.upload(t.Context(), Ask{Op: OpUpload, Upload: "late", Part: 2, Parts: 2, SHA256: sha256Hex(doc),
|
||||
Data: base64.StdEncoding.EncodeToString(p2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["complete"] != false {
|
||||
t.Errorf("the dropped part was still there: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state.
|
||||
type ippFake struct {
|
||||
url string
|
||||
body []byte
|
||||
status int
|
||||
}
|
||||
|
||||
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
|
||||
t.Helper()
|
||||
f := &ippFake{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.body, _ = io.ReadAll(r.Body)
|
||||
ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation}
|
||||
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
|
||||
ans = append(ans, ippJob)
|
||||
ans = appendAttr(ans, ippInteger, "job-id", string(binary.BigEndian.AppendUint32(nil, uint32(jobID))))
|
||||
ans = appendAttr(ans, ippEnum, "job-state", string(binary.BigEndian.AppendUint32(nil, uint32(jobState))))
|
||||
ans = appendAttr(ans, ippKeyword, "job-state-reasons", "none")
|
||||
ans = append(ans, ippEnd)
|
||||
w.Header().Set("Content-Type", "application/ipp")
|
||||
w.Write(ans)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.url = srv.URL
|
||||
old := ippURL
|
||||
ippURL = func(string) string { return srv.URL }
|
||||
t.Cleanup(func() { ippURL = old })
|
||||
return f
|
||||
}
|
||||
|
||||
// request splits what the fake was sent into its attributes and its document.
|
||||
func (f *ippFake) request(t *testing.T) (IPPAnswer, []byte) {
|
||||
t.Helper()
|
||||
a, off, err := decodeIPP(f.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a, f.body[off:]
|
||||
}
|
||||
|
||||
// 1453/AC12: printer.print sends a kept scan to the printer as an IPP Print-Job (operation 0x0002): the document
|
||||
// bytes after the attributes, document-format by sniffing, copies, sides and media as asked, to the printer's own
|
||||
// IPP address; the answer is the job's id and state.
|
||||
func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
|
||||
fake := newIPPFake(t, 77, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(5000)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": key, "copies": float64(2), "sides": "two-sided-long-edge", "media": "na_letter_8.5x11in"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(77) || m["job-state"] != "processing" || m["format"] != "application/pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if binary.BigEndian.Uint16(fake.body[2:4]) != 0x0002 {
|
||||
t.Errorf("operation = 0x%04x, want Print-Job 0x0002", binary.BigEndian.Uint16(fake.body[2:4]))
|
||||
}
|
||||
attrs, sent := fake.request(t)
|
||||
if !bytes.Equal(sent, doc) {
|
||||
t.Errorf("the document sent is not the kept one (%d bytes, want %d)", len(sent), len(doc))
|
||||
}
|
||||
text := func(n string) string { return strings.Join(attrs.texts(n), ",") }
|
||||
if text("document-format") != "application/pdf" || text("sides") != "two-sided-long-edge" || text("media") != "na_letter_8.5x11in" ||
|
||||
text("printer-uri") != "ipp://192.0.2.10/ipp/port1" {
|
||||
t.Errorf("attributes = %v", attrs.Attributes)
|
||||
}
|
||||
if c := attrs.Attributes["copies"]; len(c) != 1 || c[0].Integer != 2 {
|
||||
t.Errorf("copies = %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id.
|
||||
func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
|
||||
fake := newIPPFake(t, 8, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...)
|
||||
if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.(map[string]any)["job-state"] != "pending" {
|
||||
t.Errorf("answer = %v", got)
|
||||
}
|
||||
attrs, sent := fake.request(t)
|
||||
if !bytes.Equal(sent, jpg) || strings.Join(attrs.texts("document-format"), "") != "image/jpeg" ||
|
||||
strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 {
|
||||
t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent))
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") {
|
||||
t.Errorf("an upload never made: error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC12: a print that is not well formed is refused before anything is queued; the printer's refusal is an
|
||||
// error naming its IPP status; a printer not set is said by name.
|
||||
func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
key := sha256Hex([]byte("x")) + ".pdf"
|
||||
for name, args := range map[string]map[string]any{
|
||||
"neither": {},
|
||||
"both": {"object": key, "upload": "u"},
|
||||
"copies 0": {"object": key, "copies": float64(-1)},
|
||||
"copies 100": {"object": key, "copies": float64(100)},
|
||||
"sides": {"object": key, "sides": "sideways"},
|
||||
"media": {"object": key, "media": "A4 paper"},
|
||||
"not a key": {"object": "../x.pdf"},
|
||||
"copies float": {"object": key, "copies": 1.5},
|
||||
} {
|
||||
if _, err := r.call(t, "printer.print", args); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
if r.bus.asks() != 0 || fake.body != nil {
|
||||
t.Errorf("%d asks queued, the printer was sent %d bytes", r.bus.asks(), len(fake.body))
|
||||
}
|
||||
doc := pdfOf(100)
|
||||
dk := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), dk, "application/pdf", doc)
|
||||
fake.status = 0x0400
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": dk}); err == nil || !strings.Contains(err.Error(), "0x0400") {
|
||||
t.Errorf("a refused job: error = %v", err)
|
||||
}
|
||||
// A kept object that is not a document the printer takes is not sent.
|
||||
fake.status, fake.body = 0, nil
|
||||
r.objects.Put(t.Context(), key, "text/plain", []byte("hello"))
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") || fake.body != nil {
|
||||
t.Errorf("a text object: error = %v, sent %d bytes", err, len(fake.body))
|
||||
}
|
||||
un := newRig(t, `{}`, "pw")
|
||||
if _, err := un.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "printer is not set") || un.bus.asks() != 0 {
|
||||
t.Errorf("no printer: error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The Print-Job request is the one RFC 8011 gives: header, operation attributes in order, the job's own, the
|
||||
// end-of-attributes tag, then the document.
|
||||
func TestThePrintJobRequestIsWellFormed(t *testing.T) {
|
||||
doc := []byte("%PDF-1.4 hello")
|
||||
b := EncodePrintJob("ipp://192.0.2.10/ipp/port1", PrintOptions{Format: "application/pdf", Copies: 3, Sides: "one-sided", Media: DefaultMedia, Name: "n"}, doc)
|
||||
if !bytes.Equal(b[:8], []byte{2, 0, 0, 2, 0, 0, 0, 1}) || b[8] != ippOperation {
|
||||
t.Errorf("header = %x", b[:9])
|
||||
}
|
||||
a, off, err := decodeIPP(b)
|
||||
if err != nil || !bytes.Equal(b[off:], doc) {
|
||||
t.Fatalf("decode: %v, document %q", err, b[off:])
|
||||
}
|
||||
if first := string(b[9:]); !strings.HasPrefix(first[3:], "attributes-charset") {
|
||||
t.Errorf("the first attribute is not attributes-charset")
|
||||
}
|
||||
if len(a.Attributes["copies"]) != 1 || a.Attributes["copies"][0].Integer != 3 {
|
||||
t.Errorf("copies = %v", a.Attributes["copies"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: a scan and a print are acknowledged before the work (a slow one is not delivered again and done twice);
|
||||
// the other operations are acknowledged after it.
|
||||
func TestAScanAndAPrintAreAcknowledgedBeforeTheWork(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: true})
|
||||
body, _ := json.Marshal(Ask{ID: "s1", Op: OpScan, At: fixedNow})
|
||||
calls := 0
|
||||
var postsAtAck int
|
||||
act, _ := r.worker.Handle(body, func() { calls++; postsAtAck = f.posts })
|
||||
if calls != 1 || postsAtAck != 0 || act != actionSettled || f.posts != 1 {
|
||||
t.Errorf("early called %d times with %d scans begun; action %v; %d scans in all", calls, postsAtAck, act, f.posts)
|
||||
}
|
||||
body, _ = json.Marshal(Ask{ID: "s2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
|
||||
calls = 0
|
||||
if act, _ := r.worker.Handle(body, func() { calls++ }); calls != 0 || act != actionAck {
|
||||
t.Errorf("settings: early called %d times, action %v", calls, act)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC11: a part of 700 KiB is taken and one of a byte more is refused, by the module's own message naming
|
||||
// 700 KiB, before it is queued; the limit and the size the module cuts are one.
|
||||
func TestAPartOverSevenHundredKiBIsRefusedByTheModulesOwnMessage(t *testing.T) {
|
||||
if MaxPart != PartSize || PartSize != 700<<10 {
|
||||
t.Fatalf("MaxPart %d, PartSize %d: both are 700 KiB", MaxPart, PartSize)
|
||||
}
|
||||
r := newRig(t, `{}`, "pw")
|
||||
big := make([]byte, 700<<10+1)
|
||||
_, err := upload(t, r, "over", big, sha256Hex(big), 1, 1, big)
|
||||
if err == nil || !strings.Contains(err.Error(), "700 KiB") {
|
||||
t.Fatalf("a part of 700 KiB + 1 byte: error = %v, want the module's own message naming 700 KiB", err)
|
||||
}
|
||||
if r.bus.asks() != 0 {
|
||||
t.Errorf("an oversized part was queued")
|
||||
}
|
||||
ok := pdfOf(700 << 10)
|
||||
if _, err := upload(t, r, "exact", ok, sha256Hex(ok), 1, 1, ok); err != nil {
|
||||
t.Errorf("a part of exactly 700 KiB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1535: a scan larger than the state's value limit (256 KiB) is filed whole, its SHA-256 equal, and the answer to
|
||||
// the scan itself is small: it carries the event's body and never the document. Every part answer fits the state.
|
||||
func TestAScanLargerThanTheStatesValueLimitIsFiledWholeAndItsAnswerIsSmall(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
// The real capture is small; the worker keeps a stand-in of 3 parts' size under the key the scan names.
|
||||
big := pdfOf(222388 + 2*AnswerPartSize)
|
||||
if len(big) <= StateMaxValue {
|
||||
t.Fatal("the stand-in is not larger than the state's value limit")
|
||||
}
|
||||
r.worker.Capture = func(ctx context.Context) (Capture, error) {
|
||||
return Capture{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}, nil
|
||||
}
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, _ := body["path"].(string)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || sha256Hex(got) != sha256Hex(big) || filepath.Dir(path) != folder {
|
||||
t.Fatalf("filed %s: %v, SHA-256 equal %v", path, err, err == nil && sha256Hex(got) == sha256Hex(big))
|
||||
}
|
||||
for id, raw := range r.answers.m {
|
||||
if len(raw) > StateMaxValue {
|
||||
t.Errorf("answer %s is %d bytes, over the state's %d", id, len(raw), StateMaxValue)
|
||||
}
|
||||
}
|
||||
for _, a := range r.bus.published {
|
||||
if a.Op == OpScan {
|
||||
if raw := r.answers.get(a.ID); len(raw) > 4<<10 || bytes.Contains(raw, []byte(`"data"`)) {
|
||||
t.Errorf("the scan's answer is %d bytes and may carry the document: %s", len(raw), raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1535: an answer the state refuses ends the ask: a small error answer says why, and the queued message is
|
||||
// terminated, never redelivered into the same failure; a scan's message, acknowledged early, stays settled.
|
||||
func TestARefusedAnswerEndsTheAskWithAnErrorAndNoRedelivery(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
big := pdfOf(300 << 10)
|
||||
key := sha256Hex(big) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", big)
|
||||
// A part answer of 700 KiB, as the old cut made it, is over the state's limit; the worker must not loop on it.
|
||||
r.worker.answerPart = PartSize
|
||||
ask, _ := json.Marshal(Ask{ID: "big1", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
|
||||
act, _ := r.worker.Handle(ask, nil)
|
||||
if act != actionTerm {
|
||||
t.Errorf("action = %v, want term", act)
|
||||
}
|
||||
raw := r.answers.get("big1")
|
||||
if raw == nil || !bytes.Contains(raw, []byte(`"error"`)) || len(raw) > 4<<10 {
|
||||
t.Errorf("no small error answer: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 1535: only a refused value (error code 10054, message too large) ends the ask; a store that is away, a timeout
|
||||
// or a cut connection is redelivered, a document fetch being safe to repeat.
|
||||
func TestOnlyATooLargeAnswerEndsTheAskATransientFailureIsRetried(t *testing.T) {
|
||||
r := newRig(t, `{}`, "pw")
|
||||
big := pdfOf(300 << 10)
|
||||
key := sha256Hex(big) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", big)
|
||||
ask, _ := json.Marshal(Ask{ID: "t1", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
|
||||
r.answers.fail = fmt.Errorf("nats: timeout")
|
||||
if act, d := r.worker.Handle(ask, nil); act != actionRetry || d <= 0 {
|
||||
t.Errorf("a timeout: action %v delay %v, want retry", act, d)
|
||||
}
|
||||
r.answers.fail = nil
|
||||
r.worker.answerPart = PartSize
|
||||
ask2, _ := json.Marshal(Ask{ID: "t2", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
|
||||
if act, _ := r.worker.Handle(ask2, nil); act != actionTerm {
|
||||
t.Errorf("a too large answer: action %v, want term", act)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// eSCL (the AirScan protocol) over HTTP: POST <base>/ScanJobs answers 201 and the job's Location, GET
|
||||
// <Location>/NextDocument answers the image, and a second NextDocument answers 404 once the job is done (that is what closes it; DELETE of the job does too).
|
||||
|
||||
// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes.
|
||||
const maxImage = 256 << 20
|
||||
|
||||
// Client speaks eSCL to one scanner.
|
||||
type Client struct {
|
||||
Base string
|
||||
HTTP *http.Client
|
||||
RetryWait time.Duration // the wait before the one retry of a busy scanner (default 4 s)
|
||||
Logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan.
|
||||
func NewClient(base string) *Client {
|
||||
return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}}
|
||||
}
|
||||
|
||||
// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in.
|
||||
type Capabilities struct {
|
||||
MaxWidth, MaxHeight int
|
||||
}
|
||||
|
||||
type capabilitiesXML struct {
|
||||
MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"`
|
||||
MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"`
|
||||
}
|
||||
|
||||
// Status is what /ScannerStatus says.
|
||||
type Status struct {
|
||||
State string `xml:"State" json:"state"`
|
||||
AdfState string `xml:"AdfState" json:"adf-state"`
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.HTTP.Do(req)
|
||||
}
|
||||
|
||||
func readBody(resp *http.Response, limit int64) ([]byte, error) {
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
}
|
||||
|
||||
// Capabilities asks the scanner how large its glass is.
|
||||
func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
|
||||
resp, err := c.get(ctx, c.Base+"/ScannerCapabilities")
|
||||
if err != nil {
|
||||
return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err)
|
||||
}
|
||||
body, err := readBody(resp, 1<<20)
|
||||
if err != nil {
|
||||
return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status)
|
||||
}
|
||||
var x capabilitiesXML
|
||||
if err := xml.Unmarshal(body, &x); err != nil {
|
||||
return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err)
|
||||
}
|
||||
if x.MaxWidth <= 0 || x.MaxHeight <= 0 {
|
||||
return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size")
|
||||
}
|
||||
return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil
|
||||
}
|
||||
|
||||
// Status asks what the scanner is doing.
|
||||
func (c *Client) Status(ctx context.Context) (Status, error) {
|
||||
resp, err := c.get(ctx, c.Base+"/ScannerStatus")
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("the scanner does not answer: %w", err)
|
||||
}
|
||||
body, err := readBody(resp, 1<<20)
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status)
|
||||
}
|
||||
var s Status
|
||||
if err := xml.Unmarshal(body, &s); err != nil {
|
||||
return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// colorModes maps the config's words to eSCL's.
|
||||
var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"}
|
||||
|
||||
// ScanSettings is the request body: the Platen, the whole glass, the colour mode and resolution, as a JPEG.
|
||||
func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) {
|
||||
escl, ok := colorModes[colorMode]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("color-mode %q is not color or gray", colorMode)
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
|
||||
<pwg:Version>2.63</pwg:Version>
|
||||
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>%d</pwg:Width><pwg:Height>%d</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
|
||||
<pwg:InputSource>Platen</pwg:InputSource>
|
||||
<scan:ColorMode>%s</scan:ColorMode>
|
||||
<scan:XResolution>%d</scan:XResolution><scan:YResolution>%d</scan:YResolution>
|
||||
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
||||
</scan:ScanSettings>
|
||||
`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil
|
||||
}
|
||||
|
||||
// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the
|
||||
// real one is the image's own density (Density), for the real scanner answered 300 with 200.
|
||||
//
|
||||
// A job stays open on the scanner (pwg:State Processing, and 503 to the next ScanJobs) until NextDocument has
|
||||
// answered 404 or the job is DELETEd, so Scan always ends the job it made: after the page it asks NextDocument
|
||||
// again, and on any failure after the job was created it deletes the job.
|
||||
func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) {
|
||||
caps, err := c.Capabilities(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := ScanSettings(caps, resolution, colorMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job, err := c.createJob(ctx, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc := job + "/NextDocument"
|
||||
image, err := c.page(ctx, doc)
|
||||
if err != nil {
|
||||
c.deleteJob(job)
|
||||
return nil, err
|
||||
}
|
||||
closed, extra := c.closeJob(ctx, doc)
|
||||
if !closed {
|
||||
c.deleteJob(job)
|
||||
}
|
||||
if extra > 0 {
|
||||
return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// leftOpen is the job URL, per scanner, that brother could neither finish nor delete.
|
||||
var leftOpen sync.Map
|
||||
|
||||
func (c *Client) logf(format string, a ...any) {
|
||||
if c.Logf != nil {
|
||||
c.Logf(format, a...)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
}
|
||||
|
||||
func (c *Client) retryWait() time.Duration {
|
||||
if c.RetryWait > 0 {
|
||||
return c.RetryWait
|
||||
}
|
||||
return 4 * time.Second
|
||||
}
|
||||
|
||||
// createJob posts the settings and returns the job's URL. A 503 is a scanner busy with another job; when the job
|
||||
// brother itself left open on this scanner is now closed, the post goes once more, never more than once.
|
||||
func (c *Client) createJob(ctx context.Context, settings string) (string, error) {
|
||||
resp, reply, err := c.postJob(ctx, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
if left, ok := leftOpen.Load(c.Base); ok {
|
||||
select {
|
||||
case <-time.After(c.retryWait()):
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if c.deleteJob(left.(string)) {
|
||||
if resp, reply, err = c.postJob(ctx, settings); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return "", fmt.Errorf("the scanner is busy with another job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
|
||||
}
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("the scanner took the scan job but named no Location")
|
||||
}
|
||||
base, _ := url.Parse(c.Base + "/")
|
||||
ref, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err)
|
||||
}
|
||||
return strings.TrimRight(base.ResolveReference(ref).String(), "/"), nil
|
||||
}
|
||||
|
||||
func (c *Client) postJob(ctx context.Context, settings string) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", strings.NewReader(settings))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the scanner does not answer: %w", err)
|
||||
}
|
||||
reply, _ := readBody(resp, 1<<20)
|
||||
return resp, reply, nil
|
||||
}
|
||||
|
||||
// page fetches the job's one page.
|
||||
func (c *Client) page(ctx context.Context, doc string) ([]byte, error) {
|
||||
got, err := c.get(ctx, doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err)
|
||||
}
|
||||
image, err := readBody(got, maxImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scanned page cannot be read: %w", err)
|
||||
}
|
||||
if got.StatusCode == http.StatusGone {
|
||||
return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status)
|
||||
}
|
||||
if got.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("the scanner gave no page: %s", got.Status)
|
||||
}
|
||||
if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
|
||||
return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// maxClosing bounds the NextDocument requests that ask for the end of a job.
|
||||
const maxClosing = 1
|
||||
|
||||
// closeJob asks NextDocument until the scanner answers 404, which ends the job; true when it did (or the job is
|
||||
// gone already), and how many further pages it sent instead; any is an error for the caller, for a Platen job holds
|
||||
// one page. It stops at the first further page, and the job is then deleted.
|
||||
func (c *Client) closeJob(ctx context.Context, doc string) (closed bool, extra int) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
defer cancel()
|
||||
for i := 0; i < maxClosing; i++ {
|
||||
resp, err := c.get(ctx, doc)
|
||||
if err != nil {
|
||||
c.logf("brother: closing the scan job: %v", err)
|
||||
return false, 0
|
||||
}
|
||||
readBody(resp, maxImage)
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotFound, http.StatusGone:
|
||||
return true, 0
|
||||
case http.StatusOK:
|
||||
c.logf("brother: the scanner sent a further page after the glass's one")
|
||||
return false, 1
|
||||
}
|
||||
c.logf("brother: closing the scan job: NextDocument answered %s", resp.Status)
|
||||
return false, 0
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// deleteJob deletes the job, best effort; true when the job is gone. A job that cannot be deleted is remembered, so
|
||||
// a 503 on the next scan can be told from another client's job.
|
||||
func (c *Client) deleteJob(job string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, job, nil)
|
||||
if err == nil {
|
||||
var resp *http.Response
|
||||
if resp, err = c.HTTP.Do(req); err == nil {
|
||||
readBody(resp, 1<<20)
|
||||
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
leftOpen.Delete(c.Base)
|
||||
return true
|
||||
}
|
||||
err = fmt.Errorf("answered %s", resp.Status)
|
||||
}
|
||||
}
|
||||
c.logf("brother: deleting the scan job %s: %v", job, err)
|
||||
leftOpen.Store(c.Base, job)
|
||||
return false
|
||||
}
|
||||
|
||||
// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm).
|
||||
func Density(jpegBytes []byte) (int, error) {
|
||||
b := jpegBytes
|
||||
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return 0, fmt.Errorf("the image is not a JPEG")
|
||||
}
|
||||
for i := 2; i+4 <= len(b) && b[i] == 0xFF; {
|
||||
marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3])
|
||||
if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" {
|
||||
units := b[i+11]
|
||||
x := int(b[i+12])<<8 | int(b[i+13])
|
||||
switch {
|
||||
case units == 1 && x > 0:
|
||||
return x, nil
|
||||
case units == 2 && x > 0:
|
||||
return int(float64(x)*2.54 + 0.5), nil
|
||||
}
|
||||
return 0, fmt.Errorf("the image's JFIF header gives no resolution")
|
||||
}
|
||||
if marker == 0xDA || n < 2 {
|
||||
break
|
||||
}
|
||||
i += 2 + n
|
||||
}
|
||||
return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown")
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func squeeze(s string) string { return strings.ReplaceAll(s, "\n", "") }
|
||||
|
||||
// 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server.
|
||||
func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
image, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.posts != 1 {
|
||||
t.Fatalf("%d ScanJobs posts, want 1", f.posts)
|
||||
}
|
||||
// The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507
|
||||
// 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg.
|
||||
if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want {
|
||||
t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
// The page, then the second request that the scanner answers 404, which closes the job.
|
||||
if f.documents != 2 || f.open || f.deletes != 0 {
|
||||
t.Fatalf("%d NextDocument requests, job open %v, %d DELETEs, want 2, closed, 0", f.documents, f.open, f.deletes)
|
||||
}
|
||||
if want := fixture(t, "scan.jpg"); string(image) != string(want) {
|
||||
t.Fatalf("the returned image is %d bytes, want the scanner's %d", len(image), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC1: the colour mode and resolution asked are the configured ones.
|
||||
func TestScanAsksForTheConfiguredResolutionAndColourMode(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
if _, err := NewClient(f.base()).Scan(context.Background(), 600, "gray"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"<scan:ColorMode>Grayscale8</scan:ColorMode>", "<scan:XResolution>600</scan:XResolution>", "<scan:YResolution>600</scan:YResolution>"} {
|
||||
if !strings.Contains(f.postBody, want) {
|
||||
t.Errorf("the job lacks %s:\n%s", want, f.postBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC1: a job the scanner refuses is an error that says so, and no page is fetched.
|
||||
func TestScanSaysWhyAScannerRefusal(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.refuse = 503
|
||||
_, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
|
||||
if err == nil || !strings.Contains(err.Error(), "503") {
|
||||
t.Fatalf("error = %v, want the refusal's 503", err)
|
||||
}
|
||||
if f.documents != 0 {
|
||||
t.Fatalf("%d documents fetched after a refusal", f.documents)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC2: the image's own density, never the request's.
|
||||
func TestDensityIsTheImagesOwn(t *testing.T) {
|
||||
dpi, err := Density(fixture(t, "scan.jpg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dpi != 200 {
|
||||
t.Fatalf("density = %d dpi, want the captured scan's 200 (the request said 300)", dpi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDensityOfAnImageWithNoneIsAnError(t *testing.T) {
|
||||
if _, err := Density([]byte{0xFF, 0xD8, 0xFF, 0xDA, 0, 2}); err == nil {
|
||||
t.Fatal("an image with no JFIF header has a resolution")
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC8: the captured status replies.
|
||||
func TestStatusReadsStateAndAdfState(t *testing.T) {
|
||||
f := newFakeScanner(t, nil)
|
||||
s, err := NewClient(f.base()).Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.State != "Idle" || s.AdfState != "ScannerAdfEmpty" {
|
||||
t.Fatalf("status = %+v, want Idle / ScannerAdfEmpty", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC7: which value is unset is said by name.
|
||||
func TestMissingNamesWhatIsUnset(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
s Settings
|
||||
want string
|
||||
}{
|
||||
{Settings{Folder: "/x"}, "scanner"},
|
||||
{Settings{Scanner: "http://192.0.2.10/eSCL"}, "scan-folder"},
|
||||
} {
|
||||
err := c.s.Missing()
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("Missing(%+v) = %v, want it to name %s", c.s, err, c.want)
|
||||
}
|
||||
}
|
||||
if err := (Settings{Scanner: "a", Folder: "/x"}).Missing(); err != nil {
|
||||
t.Errorf("both set: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: two scans in a row both succeed, for the scanner answers 503 to a new job while one is open.
|
||||
func TestTwoScansInARowBothSucceed(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
c := NewClient(f.base())
|
||||
for i := 1; i <= 2; i++ {
|
||||
if _, err := c.Scan(context.Background(), 300, "color"); err != nil {
|
||||
t.Fatalf("scan %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if f.posts != 2 || f.open {
|
||||
t.Fatalf("%d posts, job open %v, want 2, closed", f.posts, f.open)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a page that cannot be had (the answer is lost) leaves no job open: the job is DELETEd.
|
||||
func TestAJobWhoseNextDocumentFailsIsDeleted(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.dropConn = true
|
||||
if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err == nil {
|
||||
t.Fatal("a lost answer is a scan")
|
||||
}
|
||||
if f.deletes != 1 || f.open {
|
||||
t.Fatalf("%d DELETEs, job open %v, want 1, closed", f.deletes, f.open)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.dropConn = false
|
||||
f.mu.Unlock()
|
||||
if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err != nil {
|
||||
t.Fatalf("the scan after it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a job with no page (404) is deleted too.
|
||||
func TestAJobThatGivesNoPageIsDeleted(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.noPage = true
|
||||
if _, err := NewClient(f.base()).Scan(context.Background(), 300, "color"); err == nil {
|
||||
t.Fatal("no page is a scan")
|
||||
}
|
||||
if f.open {
|
||||
t.Fatal("the job stays open")
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a 410 on NextDocument is said as a dropped job, and the job is deleted.
|
||||
func TestAGoneJobIsSaidAsDroppedAndDeleted(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.gone = true
|
||||
_, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
|
||||
if err == nil || !strings.Contains(err.Error(), "the scanner dropped the job") {
|
||||
t.Fatalf("error = %v, want the scanner dropped the job", err)
|
||||
}
|
||||
if f.deletes != 1 {
|
||||
t.Fatalf("%d DELETEs, want 1", f.deletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a 503 while another job is open is said as busy, with no loop and no retry when brother left no job.
|
||||
func TestABusyScannerIsSaidAndNotRetriedWhenBrotherLeftNoJob(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.open = true // another client's job
|
||||
c := NewClient(f.base())
|
||||
c.RetryWait = time.Millisecond
|
||||
_, err := c.Scan(context.Background(), 300, "color")
|
||||
if err == nil || !strings.Contains(err.Error(), "the scanner is busy with another job") {
|
||||
t.Fatalf("error = %v, want busy", err)
|
||||
}
|
||||
if f.posts != 1 {
|
||||
t.Fatalf("%d posts, want 1", f.posts)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a job brother left open (it could not close it) is closed after a wait, and the refused POST goes once more.
|
||||
func TestABusyScannerWithABrotherJobLeftIsRetriedOnceAfterItCloses(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.stuck = true
|
||||
c := NewClient(f.base())
|
||||
c.RetryWait = time.Millisecond
|
||||
if _, err := c.Scan(context.Background(), 300, "color"); err == nil {
|
||||
t.Fatal("a stuck job is a scan")
|
||||
}
|
||||
f.mu.Lock()
|
||||
if !f.open {
|
||||
t.Fatal("the fake should have kept the job open")
|
||||
}
|
||||
f.stuck = false
|
||||
f.mu.Unlock()
|
||||
c2 := NewClient(f.base())
|
||||
c2.RetryWait = time.Millisecond
|
||||
if _, err := c2.Scan(context.Background(), 300, "color"); err != nil {
|
||||
t.Fatalf("scan after the left job closes: %v", err)
|
||||
}
|
||||
if f.posts != 3 {
|
||||
t.Fatalf("%d posts, want 3 (stuck job, refused, retried)", f.posts)
|
||||
}
|
||||
}
|
||||
|
||||
// 1531: a second page for a Platen job is an error, never a silently incomplete scan, and the job is closed.
|
||||
func TestMorePagesThanTheGlassHoldsIsAnErrorAndTheJobIsClosed(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.pages = 2
|
||||
image, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
|
||||
if err == nil || !strings.Contains(err.Error(), "the scanner gave more pages than the glass holds") {
|
||||
t.Fatalf("image %d bytes, error = %v, want more pages than the glass holds", len(image), err)
|
||||
}
|
||||
if f.open || f.deletes != 1 {
|
||||
t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileMode is the mode a created scan folder gets and ScanMode the mode of a filed scan.
|
||||
const (
|
||||
FolderMode os.FileMode = 0o755
|
||||
ScanMode os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// File writes the PDF into the folder as scan-YYYY-MM-DD-HHMMSS.pdf (local time): whole beside its place as
|
||||
// .scan-*.tmp and then renamed, so nothing ever sees half a scan. A name taken already gets -2, -3, and a file
|
||||
// that is there is never overwritten. The folder is created when it is not there. Nothing in it that this
|
||||
// writes is touched but what it makes (1440/AC5, AC11).
|
||||
func File(folder string, pdf []byte, at time.Time) (string, error) {
|
||||
if !filepath.IsAbs(folder) {
|
||||
return "", fmt.Errorf("scan-folder %q is not an absolute path", folder)
|
||||
}
|
||||
if _, err := os.Stat(folder); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(folder, FolderMode); err != nil {
|
||||
return "", fmt.Errorf("the scan folder cannot be made: %w", err)
|
||||
}
|
||||
// The umask may have narrowed it; a folder this made is 0755 whatever the umask.
|
||||
if err := os.Chmod(folder, FolderMode); err != nil {
|
||||
return "", fmt.Errorf("the scan folder cannot be made: %w", err)
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(folder, ".scan-*.tmp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the scan cannot be written in %s: %w", folder, err)
|
||||
}
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(pdf); err != nil {
|
||||
return "", fmt.Errorf("the scan cannot be written: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", fmt.Errorf("the scan cannot be written: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("the scan cannot be written: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), ScanMode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stem := "scan-" + at.Local().Format("2006-01-02-150405")
|
||||
for n := 1; n < 10000; n++ {
|
||||
name := stem + ".pdf"
|
||||
if n > 1 {
|
||||
name = fmt.Sprintf("%s-%d.pdf", stem, n)
|
||||
}
|
||||
final := filepath.Join(folder, name)
|
||||
// The name is taken by creating it exclusively; the finished file is then renamed over that empty
|
||||
// one, so a scan never replaces a file that was there.
|
||||
f, err := os.OpenFile(final, os.O_CREATE|os.O_EXCL|os.O_WRONLY, ScanMode)
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the scan cannot be named in %s: %w", folder, err)
|
||||
}
|
||||
f.Close()
|
||||
if err := os.Rename(tmp.Name(), final); err != nil {
|
||||
os.Remove(final)
|
||||
return "", fmt.Errorf("the scan cannot be put in place: %w", err)
|
||||
}
|
||||
done = true
|
||||
return final, nil
|
||||
}
|
||||
return "", fmt.Errorf("no free scan name for %s in %s", stem, folder)
|
||||
}
|
||||
|
||||
// Entry is one filed scan, as scan_list says it.
|
||||
type Entry struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
// List is the newest PDFs named scan-*.pdf in the folder, newest first, at most limit. A folder that is not
|
||||
// there yet is an empty list.
|
||||
func List(folder string, limit int) ([]Entry, error) {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if os.IsNotExist(err) {
|
||||
return []Entry{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan folder cannot be read: %w", err)
|
||||
}
|
||||
out := []Entry{}
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(n, "scan-") || !strings.HasSuffix(n, ".pdf") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
out = append(out, Entry{Path: filepath.Join(folder, n), Bytes: info.Size(), Modified: info.ModTime()})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if !out[i].Modified.Equal(out[j].Modified) {
|
||||
return out[i].Modified.After(out[j].Modified)
|
||||
}
|
||||
return out[i].Path > out[j].Path
|
||||
})
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var scanName = regexp.MustCompile(`^scan-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.pdf$`)
|
||||
|
||||
// 1440/AC5: a valid one-page PDF holding the JPEG as DCTDecode, at the image's real size.
|
||||
func TestPDFHoldsTheJPEGAtItsRealSize(t *testing.T) {
|
||||
jpg := fixture(t, "scan.jpg")
|
||||
pdf, err := PDF(jpg, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(pdf)
|
||||
if !strings.HasPrefix(s, "%PDF-1.4") || !strings.HasSuffix(s, "%%EOF\n") {
|
||||
t.Fatalf("not a PDF: starts %q ends %q", s[:8], s[len(s)-6:])
|
||||
}
|
||||
if !bytes.Contains(pdf, jpg) {
|
||||
t.Error("the JPEG is not embedded as it is")
|
||||
}
|
||||
for _, want := range []string{"/Filter /DCTDecode", "/Width 1696", "/Height 2203", "/ColorSpace /DeviceRGB", "/Count 1"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("the PDF lacks %s", want)
|
||||
}
|
||||
}
|
||||
// 1696 x 2203 px at 200 dpi is 610.56 x 793.08 pt, never the 300 dpi that was asked.
|
||||
if !strings.Contains(s, "/MediaBox [0 0 610.56 793.08]") {
|
||||
t.Errorf("page size wrong:\n%s", regexp.MustCompile(`/MediaBox[^\]]*\]`).FindString(s))
|
||||
}
|
||||
if n := strings.Count(s, "/Type /Page "); n != 1 {
|
||||
t.Errorf("%d pages", n)
|
||||
}
|
||||
// Every xref entry points at its object.
|
||||
at := strings.LastIndex(s, "startxref\n")
|
||||
xref, _ := strconv.Atoi(strings.Fields(s[at+len("startxref\n"):])[0])
|
||||
lines := strings.Split(s[xref:], "\n")
|
||||
for i := 3; i <= 7 && i < len(lines); i++ {
|
||||
off, err := strconv.Atoi(lines[i][:10])
|
||||
if err != nil || !strings.HasPrefix(s[off:], fmt.Sprintf("%d 0 obj", i-2)) {
|
||||
t.Errorf("xref entry %d (%q) does not point at object %d", i-2, lines[i], i-2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC2: the page size follows the density it is given.
|
||||
func TestPDFPageSizeFollowsTheDensity(t *testing.T) {
|
||||
jpg := fixture(t, "scan.jpg")
|
||||
a, _ := PDF(jpg, 200)
|
||||
b, _ := PDF(jpg, 300)
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("200 and 300 dpi give the same page")
|
||||
}
|
||||
if !strings.Contains(string(b), "/MediaBox [0 0 407.04 528.72]") {
|
||||
t.Errorf("300 dpi page wrong")
|
||||
}
|
||||
if _, err := PDF(jpg, 0); err == nil {
|
||||
t.Error("0 dpi accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC5: exactly one file, named by local time, no temporary left, its bytes the PDF.
|
||||
func TestFileWritesOneNamedFileAndLeavesNoTemporary(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local)
|
||||
path, err := File(folder, []byte("%PDF-x"), at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(folder, "scan-2026-10-20-190509.pdf"); path != want {
|
||||
t.Fatalf("path = %s, want %s", path, want)
|
||||
}
|
||||
entries, _ := os.ReadDir(folder)
|
||||
if len(entries) != 1 || !scanName.MatchString(entries[0].Name()) {
|
||||
t.Fatalf("folder holds %v, want the one scan", entries)
|
||||
}
|
||||
if got, _ := os.ReadFile(path); string(got) != "%PDF-x" {
|
||||
t.Fatalf("file holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC5: a name taken gets -2, -3; what was there is never overwritten.
|
||||
func TestFileNeverOverwritesAndSuffixes(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local)
|
||||
first, _ := File(folder, []byte("one"), at)
|
||||
second, err := File(folder, []byte("two"), at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, _ := File(folder, []byte("three"), at)
|
||||
for p, want := range map[string]string{
|
||||
first: "one", second: "two", third: "three",
|
||||
} {
|
||||
if got, _ := os.ReadFile(p); string(got) != want {
|
||||
t.Errorf("%s holds %q, want %q", p, got, want)
|
||||
}
|
||||
}
|
||||
if filepath.Base(second) != "scan-2026-10-20-190509-2.pdf" || filepath.Base(third) != "scan-2026-10-20-190509-3.pdf" {
|
||||
t.Errorf("names %s and %s", filepath.Base(second), filepath.Base(third))
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC11: a folder that is not there is made, 0755, and the scan is filed in it.
|
||||
func TestFileMakesAMissingFolder(t *testing.T) {
|
||||
folder := filepath.Join(t.TempDir(), "a", "scans")
|
||||
path, err := File(folder, []byte("x"), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(folder)
|
||||
if err != nil || info.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("folder: %v, mode %v, want 0755", err, info)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC11: a file in the folder that the module did not write is left as it was by every tool.
|
||||
func TestFileLeavesForeignFilesAlone(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
foreign := map[string]string{"notes.txt": "mine", ".scan-theirs.tmp": "half", "scan-2020-01-01-000000.pdf": "someone else's"}
|
||||
for n, c := range foreign {
|
||||
p := filepath.Join(folder, n)
|
||||
os.WriteFile(p, []byte(c), 0o600)
|
||||
os.Chtimes(p, old, old)
|
||||
}
|
||||
if _, err := File(folder, []byte("x"), time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := List(folder, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for n, c := range foreign {
|
||||
p := filepath.Join(folder, n)
|
||||
got, err := os.ReadFile(p)
|
||||
info, _ := os.Stat(p)
|
||||
if err != nil || string(got) != c || !info.ModTime().Equal(old) || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("%s changed: %q %v", n, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC7: a relative folder writes nothing.
|
||||
func TestFileRefusesARelativeFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if _, err := File("scans", []byte("x"), time.Now()); err == nil {
|
||||
t.Fatal("a relative folder was accepted")
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
||||
t.Fatalf("wrote %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC8: scan_list: newest first, only scan PDFs, limited.
|
||||
func TestListIsNewestFirstAndLimited(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||
for i, n := range []string{"scan-a.pdf", "scan-b.pdf", "scan-c.pdf", "other.pdf", "scan-d.txt"} {
|
||||
p := filepath.Join(folder, n)
|
||||
os.WriteFile(p, []byte("x"), 0o644)
|
||||
at := base.Add(time.Duration(i) * time.Hour)
|
||||
os.Chtimes(p, at, at)
|
||||
}
|
||||
got, err := List(folder, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, e := range got {
|
||||
names = append(names, filepath.Base(e.Path))
|
||||
}
|
||||
if strings.Join(names, ",") != "scan-c.pdf,scan-b.pdf,scan-a.pdf" {
|
||||
t.Fatalf("list = %v", names)
|
||||
}
|
||||
if got, _ = List(folder, 2); len(got) != 2 || filepath.Base(got[0].Path) != "scan-c.pdf" {
|
||||
t.Fatalf("limit 2 = %v", got)
|
||||
}
|
||||
if got, err = List(filepath.Join(folder, "none"), 5); err != nil || len(got) != 0 {
|
||||
t.Fatalf("missing folder = %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner.
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "escl", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fakeScanner serves the captured eSCL answers and records what it was asked.
|
||||
type fakeScanner struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
image []byte
|
||||
postBody string
|
||||
posts int
|
||||
documents int // NextDocument requests
|
||||
refuse int // when set, the status ScanJobs answers instead of 201
|
||||
noPage bool
|
||||
// A real scanner keeps a job open (and answers a new POST with 503) until its NextDocument has answered 404
|
||||
// or the job is DELETEd; the fake does the same.
|
||||
open bool
|
||||
delivered bool
|
||||
pages int // pages a job holds beyond the first
|
||||
served int
|
||||
deletes int
|
||||
gone bool // the first NextDocument answers 410 (the job was cancelled at the device)
|
||||
dropConn bool // the first NextDocument loses its answer
|
||||
stuck bool // the job cannot be closed: NextDocument and DELETE both fail
|
||||
}
|
||||
|
||||
func newFakeScanner(t *testing.T, image []byte) *fakeScanner {
|
||||
t.Helper()
|
||||
f := &fakeScanner{image: image}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.Write(fixture(t, "capabilities.xml"))
|
||||
})
|
||||
mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.Write(fixture(t, "status-idle.xml"))
|
||||
})
|
||||
mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := make([]byte, 1<<16)
|
||||
n, _ := r.Body.Read(b)
|
||||
f.postBody = string(b[:n])
|
||||
f.posts++
|
||||
if f.refuse != 0 {
|
||||
http.Error(w, "busy", f.refuse)
|
||||
return
|
||||
}
|
||||
if f.open {
|
||||
http.Error(w, "busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
f.open, f.delivered, f.served = true, false, 0
|
||||
// The captured Location names the real scanner's host; the test's own server stands in for it.
|
||||
w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.documents++
|
||||
if f.stuck {
|
||||
http.Error(w, "stuck", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if f.gone {
|
||||
f.gone, f.open = false, false
|
||||
http.Error(w, "gone", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if f.dropConn {
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
c, _, _ := hj.Hijack()
|
||||
c.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if f.noPage || (f.delivered && f.served > f.pages) {
|
||||
f.open = false
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f.delivered = true
|
||||
f.served++
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Write(f.image)
|
||||
})
|
||||
mux.HandleFunc("/eSCL/ScanJobs/7320ead5", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "delete only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
f.deletes++
|
||||
if f.stuck {
|
||||
http.Error(w, "stuck", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
f.open = false
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
f.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeScanner) base() string { return f.URL + "/eSCL" }
|
||||
@@ -0,0 +1,400 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
|
||||
// reads is taken out of them: the title, the links, and the forms with their fields.
|
||||
|
||||
// Link is an anchor of a page.
|
||||
type Link struct {
|
||||
Href string `json:"href"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
|
||||
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 []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.
|
||||
type Form struct {
|
||||
Action string `json:"action"`
|
||||
Method string `json:"method"`
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// Page is what a page of the admin site holds.
|
||||
type Page struct {
|
||||
Title string `json:"title"`
|
||||
Links []Link `json:"links"`
|
||||
Forms []Form `json:"forms"`
|
||||
}
|
||||
|
||||
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
|
||||
|
||||
// attrs reads a tag's attributes, names in lower case and values unescaped.
|
||||
func attrs(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
|
||||
v := m[2] + m[3] + m[4]
|
||||
out[strings.ToLower(m[1])] = html.UnescapeString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
|
||||
|
||||
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
|
||||
// the printer's pages say iso-8859-1.
|
||||
func latin1(b []byte) string {
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
r := make([]rune, len(b))
|
||||
for i, c := range b {
|
||||
r[i] = rune(c)
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
|
||||
func tagEnd(s string) int {
|
||||
var q byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case q != 0:
|
||||
if c == q {
|
||||
q = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
q = c
|
||||
case c == '>':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
|
||||
func ParsePage(body []byte) Page {
|
||||
s := latin1(body)
|
||||
var p Page
|
||||
var (
|
||||
inTitle bool
|
||||
title strings.Builder
|
||||
link *Link
|
||||
formOpen bool
|
||||
form Form
|
||||
labelFor string
|
||||
inLabel bool
|
||||
label strings.Builder
|
||||
labelIDs = map[string]string{}
|
||||
wrapped []int // fields inside a label that names none, by index in form.Fields
|
||||
selIdx = -1
|
||||
inOption bool
|
||||
optValue string
|
||||
optHasV bool
|
||||
optSel bool
|
||||
optText strings.Builder
|
||||
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 {
|
||||
inOption = false
|
||||
return
|
||||
}
|
||||
v := optValue
|
||||
if !optHasV {
|
||||
v = squash(optText.String())
|
||||
}
|
||||
f := &form.Fields[selIdx]
|
||||
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
|
||||
}
|
||||
inOption = false
|
||||
}
|
||||
closeForm := func() {
|
||||
finishOption()
|
||||
selIdx = -1
|
||||
if formOpen {
|
||||
p.Forms = append(p.Forms, form)
|
||||
}
|
||||
formOpen = false
|
||||
form = Form{}
|
||||
}
|
||||
addField := func(f Field) {
|
||||
f.row = rowFirst
|
||||
if (cellOpen && cellN == 1) || f.row == "" {
|
||||
f.row = lastText
|
||||
}
|
||||
lastText = "" // text before a control names that control alone
|
||||
form.Fields = append(form.Fields, f)
|
||||
if inLabel && labelFor == "" {
|
||||
wrapped = append(wrapped, len(form.Fields)-1)
|
||||
}
|
||||
}
|
||||
text := func(t string) {
|
||||
switch {
|
||||
case inTitle:
|
||||
title.WriteString(t)
|
||||
case inOption:
|
||||
optText.WriteString(t)
|
||||
case inTextA:
|
||||
textA.WriteString(t)
|
||||
}
|
||||
if !inTitle && !inOption && !inTextA && link == nil {
|
||||
if q := squash(t); q != "" {
|
||||
switch {
|
||||
case tailIdx >= 0:
|
||||
// the text right after a radio button is that button's, not the next field's
|
||||
if form.Fields[tailIdx].own == "" {
|
||||
form.Fields[tailIdx].own = q
|
||||
}
|
||||
case !inLabel:
|
||||
lastText = q
|
||||
}
|
||||
}
|
||||
if cellOpen && cellN == 1 {
|
||||
cellText.WriteString(t)
|
||||
}
|
||||
}
|
||||
if link != nil {
|
||||
link.Text += t
|
||||
}
|
||||
if inLabel {
|
||||
label.WriteString(t)
|
||||
}
|
||||
}
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
lt := strings.IndexByte(s[i:], '<')
|
||||
if lt < 0 {
|
||||
text(s[i:])
|
||||
break
|
||||
}
|
||||
text(s[i : i+lt])
|
||||
i += lt
|
||||
switch {
|
||||
case strings.HasPrefix(s[i:], "<!--"):
|
||||
end := strings.Index(s[i:], "-->")
|
||||
if end < 0 {
|
||||
i = len(s)
|
||||
} else {
|
||||
i += end + 3
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
|
||||
end := strings.IndexByte(s[i:], '>')
|
||||
if end < 0 {
|
||||
i = len(s)
|
||||
} else {
|
||||
i += end + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
end := tagEnd(s[i:])
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
tailIdx = -1
|
||||
raw := s[i+1 : i+end]
|
||||
i += end + 1
|
||||
closing := strings.HasPrefix(raw, "/")
|
||||
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
|
||||
name := raw
|
||||
rest := ""
|
||||
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
|
||||
name, rest = raw[:sp], raw[sp:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
if !closing && (name == "script" || name == "style") {
|
||||
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
|
||||
i += e
|
||||
} else {
|
||||
i = len(s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
a := map[string]string{}
|
||||
if !closing {
|
||||
a = attrs(rest)
|
||||
}
|
||||
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 {
|
||||
link.Text = squash(link.Text)
|
||||
p.Links = append(p.Links, *link)
|
||||
}
|
||||
link = nil
|
||||
} else if href, ok := a["href"]; ok {
|
||||
link = &Link{Href: href}
|
||||
}
|
||||
case "form":
|
||||
closeForm()
|
||||
lastText = ""
|
||||
if !closing {
|
||||
formOpen = true
|
||||
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
|
||||
if form.Method == "" {
|
||||
form.Method = "get"
|
||||
}
|
||||
}
|
||||
case "label":
|
||||
if closing {
|
||||
t := squash(label.String())
|
||||
if labelFor != "" {
|
||||
labelIDs[labelFor] = t
|
||||
}
|
||||
for _, k := range wrapped {
|
||||
form.Fields[k].own = t
|
||||
}
|
||||
inLabel, labelFor, wrapped = false, "", nil
|
||||
label.Reset()
|
||||
} else {
|
||||
inLabel, labelFor = true, a["for"]
|
||||
label.Reset()
|
||||
}
|
||||
case "input":
|
||||
if closing || a["name"] == "" {
|
||||
break
|
||||
}
|
||||
typ := strings.ToLower(a["type"])
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
|
||||
break
|
||||
}
|
||||
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
|
||||
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
|
||||
if !closing && a["name"] != "" {
|
||||
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
|
||||
selIdx = len(form.Fields) - 1
|
||||
}
|
||||
case "option":
|
||||
finishOption()
|
||||
if !closing && selIdx >= 0 {
|
||||
inOption = true
|
||||
optValue, optHasV = a["value"], false
|
||||
if _, ok := a["value"]; ok {
|
||||
optHasV = true
|
||||
}
|
||||
_, optSel = a["selected"]
|
||||
optText.Reset()
|
||||
}
|
||||
case "textarea":
|
||||
if closing {
|
||||
if inTextA && textAIdx >= 0 {
|
||||
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
|
||||
}
|
||||
inTextA = false
|
||||
} else if a["name"] != "" {
|
||||
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
|
||||
inTextA, textAIdx = true, len(form.Fields)-1
|
||||
textA.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
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.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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 scan-from-PC page, RECONSTRUCTED (its checked button and row heading are assumptions, only the form
|
||||
// actions, field names, option labels and values come from the live answer): only its structure is asserted.
|
||||
func TestTheReconstructedScanFromPCPageHasItsRadioGroupAsOneField(t *testing.T) {
|
||||
p := ParsePage([]byte(adminFixture(t, "reconstructed-scanfrompc.html")))
|
||||
if len(p.Forms) != 2 {
|
||||
t.Fatalf("forms = %+v", p.Forms)
|
||||
}
|
||||
var radios []Field
|
||||
for _, f := range p.Forms[1].Fields {
|
||||
if f.Type == "radio" {
|
||||
radios = append(radios, f)
|
||||
}
|
||||
}
|
||||
if len(radios) != 1 || radios[0].Name != "B117d" || len(radios[0].Options) != 2 ||
|
||||
radios[0].Options[0] != (Option{Value: "1", Label: "Enabled"}) || radios[0].Options[1] != (Option{Value: "0", Label: "Disabled"}) {
|
||||
t.Errorf("radios = %+v", radios)
|
||||
}
|
||||
first := p.Forms[0]
|
||||
if first.Action != "/home/status.html" || len(first.Fields) != 2 || first.Fields[0].Name != "CSRFToken" || first.Fields[1].Name != "B1892" {
|
||||
t.Errorf("first form = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a radio group with no checked button answers value "" and both options.
|
||||
func TestARadioGroupWithNothingCheckedAnswersNoValue(t *testing.T) {
|
||||
p := ParsePage([]byte(`<form><input type="radio" id="a" name="R" value="1"/><label for="a">On</label><input type="radio" id="b" name="R" value="0"/><label for="b">Off</label></form>`))
|
||||
f := fieldNamed(t, p, "R")
|
||||
if f.Value != "" || len(f.Options) != 2 {
|
||||
t.Errorf("group = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 1530: a label is never guessed from text that is not the field's: the text after a radio button, the text of
|
||||
// another form, a menu link, a layout table's menu. The label is empty instead.
|
||||
func TestAFieldIsNotLabelledByOtherTexts(t *testing.T) {
|
||||
for name, c := range map[string]struct{ html, field string }{
|
||||
"after a radio group": {`<form><input type="radio" name="R" value="1"/>Off<input type="text" name="X"/></form>`, "X"},
|
||||
"after a label": {`<form><input type="radio" id="a" name="R" value="1"/><label for="a">Disabled</label><input type="text" name="X"/></form>`, "X"},
|
||||
"in a second form": {`<form><input type="radio" name="R" value="1"/>Disabled</form><form><input type="text" name="X"/></form>`, "X"},
|
||||
"after a menu link": {`<a href="/scan/scanfrompc.html">Scan from PC</a><form><input type="text" name="X"/></form>`, "X"},
|
||||
"layout table menu": {`<table><tr><td><a href="/a">Menu</a><a href="/b">Status</a></td><td><form><input type="text" name="X"/></form></td></tr></table>`, "X"},
|
||||
} {
|
||||
if got := fieldNamed(t, ParsePage([]byte(c.html)), c.field).Label; got != "" {
|
||||
t.Errorf("%s: label = %q, want none", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package secret reads the module's own secret admin-password (novox/hq ADR 0283, ADR 0467 clause 4). It is
|
||||
// imported only by the admin process (adminproc.go), which runs as its own account, brother-admin, and alone
|
||||
// can open the file: never by the tools, which the operator account's tool runner serves and which must never
|
||||
// open a password file (novox/hq ADR 0436 §12). admin_boundary_test.go checks that.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Read is the password in the file at path. A password not given yet (no file, or an empty one) is an error
|
||||
// that names the secret and says the operator gives it at the desk, and that nothing was contacted.
|
||||
func Read(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("the secret admin-password cannot be read: %w", err)
|
||||
}
|
||||
pw := strings.TrimRight(string(raw), "\r\n")
|
||||
if pw == "" {
|
||||
return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " +
|
||||
"who gives it at their desk (ADR 0283)")
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The password is the file's content without its trailing newline; no file or an empty one is "not given",
|
||||
// said by the secret's name; the password itself is never in an error.
|
||||
func TestReadGivesThePasswordOrSaysItIsNotGiven(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "admin-password.secret")
|
||||
os.WriteFile(p, []byte("p4ss\n"), 0o600)
|
||||
if pw, err := Read(p); err != nil || pw != "p4ss" {
|
||||
t.Errorf("Read = %q, %v", pw, err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(dir, "none"), func() string { e := filepath.Join(dir, "e"); os.WriteFile(e, []byte("\r\n"), 0o600); return e }()} {
|
||||
if _, err := Read(path); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
|
||||
t.Errorf("Read(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := Read(""); err == nil || !strings.Contains(err.Error(), "BROTHER_ADMIN_PASSWORD") {
|
||||
t.Errorf("Read(\"\") error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPP (RFC 8010, RFC 8011) by hand: Get-Printer-Attributes and Print-Job, neither of which needs a login.
|
||||
|
||||
const (
|
||||
ippGetPrinterAttributes = 0x000B
|
||||
ippPrintJob = 0x0002
|
||||
ippOperation = 0x01
|
||||
ippJob = 0x02
|
||||
ippEnd = 0x03
|
||||
ippInteger = 0x21
|
||||
ippEnum = 0x23
|
||||
ippNameNoLang = 0x42
|
||||
ippKeyword = 0x44
|
||||
ippURItag = 0x45
|
||||
ippMimeType = 0x49
|
||||
ippCharset = 0x47
|
||||
ippLanguage = 0x48
|
||||
)
|
||||
|
||||
// statusAttributes are the attributes printer.status asks for.
|
||||
var statusAttributes = []string{
|
||||
"printer-state", "printer-state-reasons", "marker-names", "marker-levels", "marker-colors", "printer-make-and-model",
|
||||
}
|
||||
|
||||
// ippURL is where a printer's IPP service answers: the port IPP is registered on, and the path the printer
|
||||
// serves (CUPS shows ipp://<printer>/ipp/port1). A variable so a test can put a server of its own in its place.
|
||||
var ippURL = func(host string) string {
|
||||
h := host
|
||||
if hh, _, err := net.SplitHostPort(host); err == nil {
|
||||
h = hh
|
||||
}
|
||||
if strings.Contains(h, ":") {
|
||||
h = "[" + h + "]"
|
||||
}
|
||||
return "http://" + h + ":631/ipp/port1"
|
||||
}
|
||||
|
||||
// ippPrinterURI is the printer-uri operation attribute for a host.
|
||||
func ippPrinterURI(host string) string {
|
||||
h := host
|
||||
if hh, _, err := net.SplitHostPort(host); err == nil {
|
||||
h = hh
|
||||
}
|
||||
return "ipp://" + h + "/ipp/port1"
|
||||
}
|
||||
|
||||
func appendAttr(b []byte, tag byte, name, value string) []byte {
|
||||
b = append(b, tag)
|
||||
b = binary.BigEndian.AppendUint16(b, uint16(len(name)))
|
||||
b = append(b, name...)
|
||||
b = binary.BigEndian.AppendUint16(b, uint16(len(value)))
|
||||
return append(b, value...)
|
||||
}
|
||||
|
||||
// EncodeGetPrinterAttributes is the request: IPP 2.0, operation 0x000B, request id 1, the operation attributes
|
||||
// RFC 8011 requires first (charset, natural language, printer-uri), and the attributes asked for.
|
||||
func EncodeGetPrinterAttributes(printerURI string, requested []string) []byte {
|
||||
b := []byte{0x02, 0x00, 0x00, byte(ippGetPrinterAttributes), 0, 0, 0, 1, ippOperation}
|
||||
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
|
||||
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
|
||||
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
|
||||
for i, r := range requested {
|
||||
name := ""
|
||||
if i == 0 {
|
||||
name = "requested-attributes"
|
||||
}
|
||||
b = appendAttr(b, ippKeyword, name, r)
|
||||
}
|
||||
return append(b, ippEnd)
|
||||
}
|
||||
|
||||
// IPPValue is one value of an attribute: an integer or enum, or text.
|
||||
type IPPValue struct {
|
||||
Tag byte
|
||||
Integer int
|
||||
Text string
|
||||
}
|
||||
|
||||
// IPPAnswer is a decoded response: its status code and its attributes by name, each with its values in order.
|
||||
type IPPAnswer struct {
|
||||
Status int
|
||||
Attributes map[string][]IPPValue
|
||||
}
|
||||
|
||||
// DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of
|
||||
// integers, enums and strings, whose additional values have an empty name.
|
||||
func DecodeIPP(b []byte) (IPPAnswer, error) {
|
||||
a, _, err := decodeIPP(b)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// decodeIPP is DecodeIPP and the offset after the end-of-attributes tag, where a request's document begins.
|
||||
func decodeIPP(b []byte) (IPPAnswer, int, error) {
|
||||
a := IPPAnswer{Attributes: map[string][]IPPValue{}}
|
||||
if len(b) < 9 {
|
||||
return a, 0, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b))
|
||||
}
|
||||
a.Status = int(binary.BigEndian.Uint16(b[2:4]))
|
||||
i := 8
|
||||
last := ""
|
||||
for i < len(b) {
|
||||
tag := b[i]
|
||||
i++
|
||||
if tag == ippEnd {
|
||||
return a, i, nil
|
||||
}
|
||||
if tag <= 0x05 { // a group's start
|
||||
last = ""
|
||||
continue
|
||||
}
|
||||
if i+2 > len(b) {
|
||||
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute")
|
||||
}
|
||||
nl := int(binary.BigEndian.Uint16(b[i:]))
|
||||
i += 2
|
||||
if i+nl+2 > len(b) {
|
||||
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute name")
|
||||
}
|
||||
name := string(b[i : i+nl])
|
||||
i += nl
|
||||
vl := int(binary.BigEndian.Uint16(b[i:]))
|
||||
i += 2
|
||||
if i+vl > len(b) {
|
||||
return a, 0, fmt.Errorf("the IPP answer ends inside the value of %q", name)
|
||||
}
|
||||
raw := b[i : i+vl]
|
||||
i += vl
|
||||
if name == "" {
|
||||
name = last
|
||||
}
|
||||
last = name
|
||||
v := IPPValue{Tag: tag}
|
||||
if (tag == ippInteger || tag == ippEnum) && vl == 4 {
|
||||
v.Integer = int(int32(binary.BigEndian.Uint32(raw)))
|
||||
} else {
|
||||
v.Text = string(raw)
|
||||
}
|
||||
a.Attributes[name] = append(a.Attributes[name], v)
|
||||
}
|
||||
return a, 0, fmt.Errorf("the IPP answer has no end-of-attributes tag")
|
||||
}
|
||||
|
||||
func (a IPPAnswer) texts(name string) []string {
|
||||
var out []string
|
||||
for _, v := range a.Attributes[name] {
|
||||
out = append(out, v.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printerStates are the values RFC 8011 gives printer-state.
|
||||
var printerStates = map[int]string{3: "idle", 4: "processing", 5: "stopped"}
|
||||
|
||||
// Marker is one consumable of the printer: a toner, a drum, a belt. A level below 0 is the standard's way
|
||||
// to say it is unknown (-1) or not reported (-2).
|
||||
type Marker struct {
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
// PrinterState is what printer.status reads over IPP.
|
||||
type PrinterState struct {
|
||||
State string `json:"state"`
|
||||
StateCode int `json:"state-code"`
|
||||
Reasons []string `json:"state-reasons"`
|
||||
MakeAndModel string `json:"make-and-model"`
|
||||
Markers []Marker `json:"markers"`
|
||||
}
|
||||
|
||||
// State reads the answer into the printer's state; an answer whose status is not a success is an error.
|
||||
func (a IPPAnswer) State() (PrinterState, error) {
|
||||
if a.Status >= 0x0100 {
|
||||
return PrinterState{}, fmt.Errorf("the printer refused the IPP request with status 0x%04x", a.Status)
|
||||
}
|
||||
st := a.Attributes["printer-state"]
|
||||
if len(st) == 0 {
|
||||
return PrinterState{}, fmt.Errorf("the IPP answer holds no printer-state")
|
||||
}
|
||||
code := st[0].Integer
|
||||
name, ok := printerStates[code]
|
||||
if !ok {
|
||||
name = fmt.Sprintf("unknown (%d)", code)
|
||||
}
|
||||
s := PrinterState{State: name, StateCode: code, Reasons: a.texts("printer-state-reasons"), Markers: []Marker{}}
|
||||
if s.Reasons == nil {
|
||||
s.Reasons = []string{}
|
||||
}
|
||||
if m := a.texts("printer-make-and-model"); len(m) > 0 {
|
||||
s.MakeAndModel = m[0]
|
||||
}
|
||||
names, levels, colors := a.texts("marker-names"), a.Attributes["marker-levels"], a.texts("marker-colors")
|
||||
for i, n := range names {
|
||||
m := Marker{Name: n, Level: -1}
|
||||
if i < len(levels) {
|
||||
m.Level = levels[i].Integer
|
||||
}
|
||||
if i < len(colors) {
|
||||
m.Color = colors[i]
|
||||
}
|
||||
s.Markers = append(s.Markers, m)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PrinterStateOf asks the printer for its state over IPP. The printer is asked on its IPP port, not through the
|
||||
// pinned HTTPS client, and nothing secret is sent.
|
||||
func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
|
||||
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), statusAttributes)))
|
||||
if err != nil {
|
||||
return PrinterState{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/ipp")
|
||||
c := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return PrinterState{}, 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 PrinterState{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return PrinterState{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
||||
}
|
||||
ans, err := DecodeIPP(body)
|
||||
if err != nil {
|
||||
return PrinterState{}, err
|
||||
}
|
||||
return ans.State()
|
||||
}
|
||||
|
||||
// PrintOptions are what a Print-Job asks of the printer.
|
||||
type PrintOptions struct {
|
||||
Format string // document-format, application/pdf or image/jpeg
|
||||
Copies int
|
||||
Sides string
|
||||
Media string
|
||||
Name string // job-name
|
||||
}
|
||||
|
||||
// SniffFormat is the document format of the bytes the printer accepts: PDF or JPEG, by their first bytes, and
|
||||
// otherwise an error.
|
||||
func SniffFormat(doc []byte) (format, ext string, err error) {
|
||||
switch {
|
||||
case bytes.HasPrefix(doc, []byte("%PDF-")):
|
||||
return "application/pdf", "pdf", nil
|
||||
case len(doc) > 3 && doc[0] == 0xFF && doc[1] == 0xD8 && doc[2] == 0xFF:
|
||||
return "image/jpeg", "jpg", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("the document is neither a PDF nor a JPEG: the printer is sent those two only")
|
||||
}
|
||||
|
||||
// EncodePrintJob is the Print-Job request (RFC 8011 §4.2.1): the operation attributes in the order the standard
|
||||
// requires, the job's copies, sides and media, and the document after the end-of-attributes tag.
|
||||
func EncodePrintJob(printerURI string, o PrintOptions, doc []byte) []byte {
|
||||
b := []byte{0x02, 0x00, 0x00, byte(ippPrintJob), 0, 0, 0, 1, ippOperation}
|
||||
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
|
||||
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
|
||||
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
|
||||
b = appendAttr(b, ippNameNoLang, "requesting-user-name", "brother")
|
||||
b = appendAttr(b, ippNameNoLang, "job-name", o.Name)
|
||||
b = appendAttr(b, ippMimeType, "document-format", o.Format)
|
||||
b = append(b, ippJob)
|
||||
b = appendAttr(b, ippInteger, "copies", string(binary.BigEndian.AppendUint32(nil, uint32(o.Copies))))
|
||||
b = appendAttr(b, ippKeyword, "sides", o.Sides)
|
||||
b = appendAttr(b, ippKeyword, "media", o.Media)
|
||||
b = append(b, ippEnd)
|
||||
return append(b, doc...)
|
||||
}
|
||||
|
||||
// jobStates are the values RFC 8011 gives job-state.
|
||||
var jobStates = map[int]string{3: "pending", 4: "pending-held", 5: "processing", 6: "processing-stopped", 7: "canceled", 8: "aborted", 9: "completed"}
|
||||
|
||||
// PrintResult is what a Print-Job answered: the job's id and state.
|
||||
type PrintResult struct {
|
||||
JobID int `json:"job-id"`
|
||||
State string `json:"job-state"`
|
||||
StateCode int `json:"job-state-code"`
|
||||
Reasons []string `json:"job-state-reasons"`
|
||||
}
|
||||
|
||||
// PrintResult reads a Print-Job answer; a failing status is an error naming it.
|
||||
func (a IPPAnswer) PrintResult() (PrintResult, error) {
|
||||
if a.Status >= 0x0100 {
|
||||
return PrintResult{}, fmt.Errorf("the printer refused the print job with IPP status 0x%04x", a.Status)
|
||||
}
|
||||
id := a.Attributes["job-id"]
|
||||
if len(id) == 0 {
|
||||
return PrintResult{}, fmt.Errorf("the printer's answer to the print job names no job-id")
|
||||
}
|
||||
r := PrintResult{JobID: id[0].Integer, Reasons: a.texts("job-state-reasons")}
|
||||
if r.Reasons == nil {
|
||||
r.Reasons = []string{}
|
||||
}
|
||||
if st := a.Attributes["job-state"]; len(st) > 0 {
|
||||
r.StateCode = st[0].Integer
|
||||
r.State = jobStates[r.StateCode]
|
||||
if r.State == "" {
|
||||
r.State = fmt.Sprintf("unknown (%d)", r.StateCode)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// PrintJob sends the document to the printer's IPP service as a Print-Job, and answers the job's id and state.
|
||||
func PrintJob(ctx context.Context, host string, o PrintOptions, doc []byte) (PrintResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), bytes.NewReader(EncodePrintJob(ippPrinterURI(host), o, doc)))
|
||||
if err != nil {
|
||||
return PrintResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/ipp")
|
||||
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
||||
if err != nil {
|
||||
return PrintResult{}, fmt.Errorf("the printer's IPP service does not answer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return PrintResult{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return PrintResult{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
||||
}
|
||||
ans, err := DecodeIPP(body)
|
||||
if err != nil {
|
||||
return PrintResult{}, err
|
||||
}
|
||||
return ans.PrintResult()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ippFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "ipp", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fakeIPP replays the captured Get-Printer-Attributes answer (testdata/ipp, 2026-10-20) and records what it
|
||||
// was sent. It stands in for the printer's port 631: no test reaches a real printer.
|
||||
func fakeIPP(t *testing.T, answer []byte) (url string, got *[]byte) {
|
||||
t.Helper()
|
||||
var sent []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
if r.Header.Get("Content-Type") != "application/ipp" {
|
||||
http.Error(w, "not ipp", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/ipp")
|
||||
w.Write(answer)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL, &sent
|
||||
}
|
||||
|
||||
// 1453/AC5: the request the module encodes is byte for byte the captured one the printer answered.
|
||||
func TestTheIPPRequestIsTheCapturedOne(t *testing.T) {
|
||||
got := EncodeGetPrinterAttributes(ippPrinterURI("192.0.2.10"), statusAttributes)
|
||||
if want := ippFixture(t, "get-printer-attributes.request"); !bytes.Equal(got, want) {
|
||||
t.Errorf("request =\n%x\nwant\n%x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC5: the captured IPP answer decodes to the printer's state, its reasons and its marker levels.
|
||||
func TestTheCapturedIPPAnswerDecodes(t *testing.T) {
|
||||
ans, err := DecodeIPP(ippFixture(t, "get-printer-attributes.answer"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := ans.State()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := PrinterState{State: "idle", StateCode: 3, Reasons: []string{"toner-low-warning"},
|
||||
MakeAndModel: "Brother MFC-L8390CDW series",
|
||||
Markers: []Marker{{"BK", 40, "#000000"}, {"C", 10, "#00FFFF"}, {"M", 0, "#FF00FF"}, {"Y", 100, "#FFFF00"}}}
|
||||
if !reflect.DeepEqual(st, want) {
|
||||
t.Errorf("state = %+v\nwant %+v", st, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC5: an answer cut short, or one with a failing status, is an error and not a state.
|
||||
func TestABrokenIPPAnswerIsAnError(t *testing.T) {
|
||||
good := ippFixture(t, "get-printer-attributes.answer")
|
||||
if _, err := DecodeIPP(good[:len(good)-20]); err == nil {
|
||||
t.Errorf("a truncated answer decoded")
|
||||
}
|
||||
refused := append([]byte{}, good...)
|
||||
refused[2], refused[3] = 0x04, 0x00
|
||||
ans, err := DecodeIPP(refused)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ans.State(); err == nil || !strings.Contains(err.Error(), "0x0400") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC5: printer.status gives the IPP state and the eSCL state, replayed from the captures, and sends the
|
||||
// captured request.
|
||||
func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) {
|
||||
url, sent := fakeIPP(t, ippFixture(t, "get-printer-attributes.answer"))
|
||||
old := ippURL
|
||||
ippURL = func(string) string { return url }
|
||||
t.Cleanup(func() { ippURL = old })
|
||||
f := newFakeScanner(t, nil)
|
||||
tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now)
|
||||
got, err := tool(t, tl, "printer.status").Run(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["answers"] != true || m["state"] != "idle" || m["make-and-model"] != "Brother MFC-L8390CDW series" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if r := m["state-reasons"].([]string); !reflect.DeepEqual(r, []string{"toner-low-warning"}) {
|
||||
t.Errorf("reasons = %v", r)
|
||||
}
|
||||
if mk := m["markers"].([]Marker); len(mk) != 4 || mk[0] != (Marker{"BK", 40, "#000000"}) {
|
||||
t.Errorf("markers = %v", mk)
|
||||
}
|
||||
if sc := m["scanner"].(map[string]any); sc["answers"] != true || sc["state"] != "Idle" || sc["adf-state"] != "ScannerAdfEmpty" {
|
||||
t.Errorf("scanner = %v", sc)
|
||||
}
|
||||
if !bytes.Equal(*sent, ippFixture(t, "get-printer-attributes.request")) {
|
||||
t.Errorf("the printer was sent %x", *sent)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4 and AC5: a part that does not answer is answers false with why, the other part is still given; an
|
||||
// unset printer or scanner is said by name and nothing is contacted.
|
||||
func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) {
|
||||
old := ippURL
|
||||
ippURL = func(string) string { return "http://127.0.0.1:1/ipp/port1" }
|
||||
t.Cleanup(func() { ippURL = old })
|
||||
f := newFakeScanner(t, nil)
|
||||
tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now)
|
||||
got, err := tool(t, tl, "printer.status").Run(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["answers"] != false || m["why"] == nil || m["scanner"].(map[string]any)["answers"] != true {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
for _, c := range []struct{ js, want string }{
|
||||
{`{"scanner":"` + f.base() + `"}`, "printer is not set"},
|
||||
{`{"printer":"192.0.2.10"}`, "scanner is not set"},
|
||||
{`{}`, "printer and scanner"},
|
||||
} {
|
||||
tl := tools(settingsFile(t, c.js), time.Now)
|
||||
if _, err := tool(t, tl, "printer.status").Run(nil); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: error = %v, want %q", c.js, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "device" {
|
||||
os.Exit(device())
|
||||
}
|
||||
if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), time.Now)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// intArg reads a whole number out of a tool's arguments: a JSON number, which arrives as a float64.
|
||||
func intArg(args map[string]any, name string) (int, error) {
|
||||
v, ok := args[name]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n != float64(int(n)) {
|
||||
return 0, fmt.Errorf("%s is a whole number", name)
|
||||
}
|
||||
return int(n), nil
|
||||
case int:
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%s is a whole number", name)
|
||||
}
|
||||
|
||||
func strArg(args map[string]any, name string) (string, error) {
|
||||
v, ok := args[name]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
s, isStr := v.(string)
|
||||
if !isStr {
|
||||
return "", fmt.Errorf("%s is a string", name)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
var err error
|
||||
for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256,
|
||||
"data": &a.Data, "sides": &a.Sides, "media": &a.Media} {
|
||||
if *dst, err = strArg(args, name); err != nil {
|
||||
return a, err
|
||||
}
|
||||
}
|
||||
for name, dst := range map[string]*int{"part": &a.Part, "parts": &a.Parts, "copies": &a.Copies} {
|
||||
if *dst, err = intArg(args, name); err != nil {
|
||||
return a, err
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// tools are the scanner seat's and the printer seat's verbs, served as <seat>.<verb> (ADR 0467 clause 2, ADR 0468).
|
||||
// The settings are read at each call, so a changed config value is the next call's. None opens a credential: what
|
||||
// needs one is asked of the device process.
|
||||
func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
service := func() (*Service, error) {
|
||||
s, err := LoadSettings(settingsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
|
||||
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll}, nil
|
||||
}
|
||||
// ask is a tool that reads its arguments, and answers what the service gives.
|
||||
ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
a, err := askFrom(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), bound)
|
||||
defer cancel()
|
||||
return run(s, ctx, a)
|
||||
}
|
||||
}
|
||||
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
num := func(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "printer.upload",
|
||||
Description: "Send one part of a document to the device process, to be printed later (printer.print with the upload id). " +
|
||||
"Cut the document into parts of at most 700 KiB (700 KiB is the limit), number them from 1, and send each with the " +
|
||||
"whole document's SHA-256. When the last part has come and the joined SHA-256 matches, the document is stored as " +
|
||||
"uploads/<upload>.pdf or .jpg and the answer says complete; a wrong SHA-256 stores nothing. PDF and JPEG only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"upload", "part", "parts", "sha256", "data"}, "properties": map[string]any{
|
||||
"upload": str("an id of your choosing: letters, digits, - and _, at most 64"),
|
||||
"part": num("this part's number, from 1"),
|
||||
"parts": num("how many parts the document has"),
|
||||
"sha256": str("the whole document's SHA-256, lower-case hex"),
|
||||
"data": str("this part's bytes, base64"),
|
||||
}},
|
||||
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterUpload(ctx, a) }, 2*time.Minute),
|
||||
},
|
||||
{
|
||||
Name: "printer.print",
|
||||
Description: "Print a document on the printer: a scan kept in the bucket (object) or a completed upload (upload). The device " +
|
||||
"process sends it as an IPP Print-Job in its own format (PDF or JPEG) and answers the job's id and state.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"object": str("a scan's key, as scan.added names it"),
|
||||
"upload": str("a completed upload's id"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
|
||||
"media": str("the paper's size name (default " + DefaultMedia + ")"),
|
||||
}},
|
||||
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterPrint(ctx, a) }, 2*time.Minute),
|
||||
},
|
||||
{
|
||||
Name: "scanner.document",
|
||||
Description: "One part of a scan kept in the bucket, by the key scan.added names (object): answers part, parts (how many), " +
|
||||
"size, sha256 (of the whole document) and data (this part, base64). Join the parts and check the SHA-256.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"object", "part"}, "properties": map[string]any{
|
||||
"object": str("the scan's key"),
|
||||
"part": num("the part's number, from 1"),
|
||||
}},
|
||||
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) {
|
||||
return s.ScanDocument(ctx, a.Object, a.Part)
|
||||
}, 2*time.Minute),
|
||||
},
|
||||
{
|
||||
Name: "printer.status",
|
||||
Description: "The printer's state without a login: over IPP its state (idle, processing or stopped), its state " +
|
||||
"reasons (such as toner-low-warning), its toner and other markers with their levels in percent (below 0 " +
|
||||
"is unknown), and over eSCL the scanner's state and its document feeder's. A part that does not answer " +
|
||||
"is said as answers false with why. Read-only.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return s.PrinterStatus(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "printer.settings",
|
||||
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 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)"},
|
||||
"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) {
|
||||
page := ""
|
||||
if v, ok := args["page"]; ok {
|
||||
str, isStr := v.(string)
|
||||
if !isStr {
|
||||
return nil, fmt.Errorf("page is a string")
|
||||
}
|
||||
page = str
|
||||
}
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
links, hidden, err := boolArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.PrinterSettings(ctx, page, links, hidden)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scanner.scan",
|
||||
Description: "Scan the glass of the configured network scanner: the device process cuts the document out of the platen's " +
|
||||
"white, keeps it as a one-page PDF in the module's bucket under its sha256 and says scan.added on the bus; this " +
|
||||
"tool then fetches it and files it in the scan folder. Waits up to three minutes. Answers the event's body: " +
|
||||
"the object's key, its size and sha256, its real size in millimetres, the resolution the scanner really gave, " +
|
||||
"whether it was cropped, and the file's path. The image itself is never sent.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute)
|
||||
defer cancel()
|
||||
return s.ScanNow(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scanner.status",
|
||||
Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " +
|
||||
"and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return s.ScanStatus(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scanner.list",
|
||||
Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many scans (default 20)"},
|
||||
},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit := 20
|
||||
if v, ok := args["limit"]; ok {
|
||||
f, isNum := v.(float64)
|
||||
if i, isInt := v.(int); isInt {
|
||||
f, isNum = float64(i), true
|
||||
}
|
||||
if !isNum || f < 1 || f > 1000 || f != float64(int(f)) {
|
||||
return nil, fmt.Errorf("limit is a whole number from 1 to 1000")
|
||||
}
|
||||
limit = int(f)
|
||||
}
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ScanList(limit)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Requires []string `json:"requires"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Emits []string `json:"emits"`
|
||||
OwnSecrets map[string]map[string]any `json:"own-secrets"`
|
||||
Seats []seat `json:"seats"`
|
||||
Claims []seat `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1440/AC9: the scan folder is no manifest resource, because a directory's path cannot take a config value
|
||||
// (issue 1441, ADR 0464 clause 4): no resource's path names it, and no directory but the state one is declared.
|
||||
func TestTheScanFolderIsNoManifestResource(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if p, _ := r["path"].(string); strings.Contains(p, "${config:") || strings.Contains(p, "${setting:") {
|
||||
t.Errorf("resource %v puts a config value in a path, which composition does not fill", r["id"])
|
||||
}
|
||||
if r["type"] == "directory" && r["id"] != "state" {
|
||||
t.Errorf("directory %v: the code makes the scan folder", r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named.
|
||||
func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
// Declared in both places, as mesh-mail declares its sender's events: the module's own, and the device
|
||||
// process's bus block (the controller refuses a process's emit the module does not declare as its own,
|
||||
// ADR 0436 §1).
|
||||
if !reflect.DeepEqual(m.Emits, []string{EventAdded}) {
|
||||
t.Errorf("the module's own emits = %v, want [%s]", m.Emits, EventAdded)
|
||||
}
|
||||
bus, _ := resource(t, m, "device")["bus"].(map[string]any)
|
||||
if !reflect.DeepEqual(bus["emits"], []any{EventAdded}) {
|
||||
t.Errorf("the device process's bus emits = %v, want [%s]", bus["emits"], EventAdded)
|
||||
}
|
||||
var served []string
|
||||
for _, tl := range tools("", nil) {
|
||||
served = append(served, tl.Name)
|
||||
}
|
||||
got := append([]string{}, m.Tools...)
|
||||
sort.Strings(got)
|
||||
sort.Strings(served)
|
||||
if !reflect.DeepEqual(got, served) {
|
||||
t.Errorf("manifest tools = %v, the bundle serves %v", got, served)
|
||||
}
|
||||
// Every verb of a seat is a tool named <seat>.<verb>.
|
||||
for _, st := range m.Claims {
|
||||
for _, v := range st.Serves {
|
||||
if !slices.Contains(served, st.Name+"."+v) {
|
||||
t.Errorf("the seat %s serves %s, but no tool %s.%s is served", st.Name, v, st.Name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC9: the config reaches the code as the one file, under the name the code reads, and every key the
|
||||
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
|
||||
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
keys := []string{"scanner", "scan-folder", "printer", "printer-certificate", "resolution", "color-mode", "crop"}
|
||||
for _, k := range keys {
|
||||
if _, ok := m.Config[k]; !ok {
|
||||
t.Errorf("config does not declare %s", k)
|
||||
}
|
||||
}
|
||||
if len(m.Config) != len(keys) {
|
||||
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
|
||||
}
|
||||
// The printer's pins and addresses are written into the file as they are given, and match what the
|
||||
// code's own validators accept (ADR 0467 clause 3).
|
||||
fp := regexp.MustCompile(m.Config["printer-certificate"]["pattern"].(string))
|
||||
host := regexp.MustCompile(m.Config["printer"]["pattern"].(string))
|
||||
if !fp.MatchString(strings.Repeat("0A:", 31)+"0A") || fp.MatchString("0a:0a") || !host.MatchString("192.0.2.10") || host.MatchString("a b/c") {
|
||||
t.Errorf("the printer patterns do not say a host and a SHA-256 fingerprint")
|
||||
}
|
||||
var taken []any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "settings" {
|
||||
taken, _ = r["takes-config"].([]any)
|
||||
}
|
||||
}
|
||||
if len(taken) != len(keys) {
|
||||
t.Errorf("the settings file takes %v", taken)
|
||||
}
|
||||
env := ""
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
env, _ = a["env"].(map[string]any)["BROTHER_SETTINGS"].(string)
|
||||
}
|
||||
}
|
||||
if env != "${dir:state}/settings.json" {
|
||||
t.Errorf("BROTHER_SETTINGS = %q", env)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tpl map[string]any
|
||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the template and the manifest default say is what LoadSettings falls back to.
|
||||
d, err := LoadSettings(settingsFile(t, string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, want := range map[string]any{"resolution": float64(d.Resolution), "color-mode": d.ColorMode, "crop": d.Crop} {
|
||||
if m.Config[k]["default"] != want || tpl[k] != want {
|
||||
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"scanner", "scan-folder", "printer", "printer-certificate"} {
|
||||
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
|
||||
t.Errorf("%s is the operator's: required and trusted", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC6: the manifest declares the scanner seat, held once for the mesh, and claims it, with the verbs
|
||||
// ADR 0467 clause 2 names; the module serves each verb as the tool <seat>.<verb>.
|
||||
func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, want := range []seat{
|
||||
{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list", "document"}},
|
||||
// restart is round 2 of issue 1453, and is not declared yet.
|
||||
{Name: "printer", Scope: "mesh", Serves: []string{"status", "settings", "print", "upload"}},
|
||||
} {
|
||||
for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} {
|
||||
found := false
|
||||
for _, s := range got {
|
||||
if s.Name == want.Name {
|
||||
found = true
|
||||
if !reflect.DeepEqual(s, want) {
|
||||
t.Errorf("%s: %s = %+v, want %+v", what, want.Name, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s does not hold the %s seat", what, want.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC6 (issue 1446): the three preferences are not trusted, so a person's own setting is not refused.
|
||||
func TestThePreferencesAreNotTrusted(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, k := range []string{"resolution", "color-mode", "crop"} {
|
||||
if v, ok := m.Config[k]["trusted"]; !ok || v != false {
|
||||
t.Errorf("%s: trusted = %v, want false", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC6: the scan module is gone from the catalogue.
|
||||
func TestTheScanModuleIsGone(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", "..", "scan")); !os.IsNotExist(err) {
|
||||
t.Errorf("modules/scan still exists (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resource is the manifest's resource of this id.
|
||||
func resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1453/AC4 (ADR 0467 clause 4, ADR 0436 §1): the admin password is the module's own secret, issued by the
|
||||
// operator (never generated), and owned by an account the module makes, brother-device: the tools' runner
|
||||
// (the operator account) cannot read it, the device process (that account) can. The process's bus account is
|
||||
// the own secret broker-device, owned by the same account.
|
||||
func TestTheAdminPasswordIsTheOperatorsOwnSecretOwnedByTheAdminAccount(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
s, ok := m.OwnSecrets["admin-password"]
|
||||
if !ok {
|
||||
t.Fatal("own-secrets does not declare admin-password")
|
||||
}
|
||||
if s["issued-by"] != "outside" {
|
||||
t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"])
|
||||
}
|
||||
if s["owner"] != "brother-device" {
|
||||
t.Errorf("owner = %v, want brother-device (never root or ${machine:account}: ADR 0436 §1)", s["owner"])
|
||||
}
|
||||
if p, _ := s["prompt"].(string); p != "the admin password of the Brother printer" {
|
||||
t.Errorf("prompt = %q", p)
|
||||
}
|
||||
if b := m.OwnSecrets["broker-device"]; b["owner"] != "brother-device" || b["path"] != "${dir:state}/broker-device.secret" {
|
||||
t.Errorf("broker-device = %v", b)
|
||||
}
|
||||
acct := resource(t, m, "account")
|
||||
if acct["type"] != "user" || acct["name"] != "brother-device" || acct["shell"] != "/usr/bin/nologin" {
|
||||
t.Errorf("account = %v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453 (ADR 0467 clause 4): the device process runs as brother-device with the bus access of ADR 0436 §7: it
|
||||
// consumes the work queue device-asks and writes the state device-answers, and is judged by its unit. The
|
||||
// tools' bundle is given no password file.
|
||||
func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
p := resource(t, m, "device")
|
||||
if p["type"] != "process" || p["name"] != "brother-device" || p["user"] != "brother-device" || p["artifact"] != "tools" {
|
||||
t.Errorf("process = %v", p)
|
||||
}
|
||||
if !reflect.DeepEqual(p["run"], []any{"./brother", "device"}) {
|
||||
t.Errorf("run = %v", p["run"])
|
||||
}
|
||||
bus, _ := p["bus"].(map[string]any)
|
||||
if !reflect.DeepEqual(bus["queues"], []any{"device-asks"}) || !reflect.DeepEqual(bus["state"], []any{"device-answers"}) {
|
||||
t.Errorf("bus = %v", bus)
|
||||
}
|
||||
if h, _ := p["health"].(map[string]any); h["kind"] != "unit" {
|
||||
t.Errorf("health = %v", p["health"])
|
||||
}
|
||||
env, _ := p["env"].(map[string]any)
|
||||
want := map[string]any{
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"],
|
||||
"BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"],
|
||||
"BROTHER_STORE": "${dir:state}/store.json",
|
||||
}
|
||||
if !reflect.DeepEqual(env, want) {
|
||||
t.Errorf("env = %v, want %v", env, want)
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
if _, has := a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"]; has {
|
||||
t.Errorf("the tools' bundle is given the password file")
|
||||
}
|
||||
}
|
||||
}
|
||||
// The admin account reads the settings file and walks the state directory; neither holds a secret.
|
||||
if r := resource(t, m, "settings"); r["mode"] != "0644" {
|
||||
t.Errorf("settings mode = %v: the admin account must read it", r["mode"])
|
||||
}
|
||||
if r := resource(t, m, "state"); r["mode"] != "0711" {
|
||||
t.Errorf("state mode = %v: the admin account must reach its files in it", r["mode"])
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "device" {
|
||||
if r["takes-config"] != nil {
|
||||
for _, k := range r["takes-config"].([]any) {
|
||||
if k == "admin-password" {
|
||||
t.Errorf("the password is a config value")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1453 (ADR 0467 clause 4): the stream device-asks is a work queue like mesh-mail's mail-sends, and the
|
||||
// answers are a state that expires.
|
||||
func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
var m struct {
|
||||
Streams []map[string]any `json:"streams"`
|
||||
State []any `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Streams) != 1 {
|
||||
t.Fatalf("streams = %v", m.Streams)
|
||||
}
|
||||
st := m.Streams[0]
|
||||
if st["name"] != "device-asks" || st["retention"] != "work-queue" || st["max-deliveries"] != float64(3) ||
|
||||
!reflect.DeepEqual(st["events"], []any{EventAsked}) {
|
||||
t.Errorf("stream = %v", st)
|
||||
}
|
||||
if st["duplicates"] != float64(24) {
|
||||
t.Errorf("the stream has no duplicates window like mail-sends': %v", st["duplicates"])
|
||||
}
|
||||
if st["max-age-days"] == nil || st["max-mib"] == nil {
|
||||
t.Errorf("the stream is bounded: %v", st)
|
||||
}
|
||||
if len(m.State) != 1 || m.State[0].(map[string]any)["name"] != AnswersState || m.State[0].(map[string]any)["ttl-seconds"] == nil {
|
||||
t.Errorf("state = %v", m.State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC8 (ADR 0468 clause 2): the module requires an s3-bucket as mesh-mail does, and its facts and key are
|
||||
// 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 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)
|
||||
}
|
||||
r := resource(t, m, "store")
|
||||
if r["type"] != "file" || r["path"] != "${dir:state}/store.json" || r["mode"] != "0600" || r["owner"] != "brother-device" || r["template"] != "files/store.json" {
|
||||
t.Errorf("store = %v", r)
|
||||
}
|
||||
mine, _ := os.ReadFile(filepath.Join("..", "..", "files", "store.json"))
|
||||
theirs, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-mail", "files", "store.json"))
|
||||
if err != nil || string(mine) != string(theirs) {
|
||||
t.Errorf("files/store.json is not mesh-mail's (%v)", err)
|
||||
}
|
||||
p := resource(t, m, "device")
|
||||
if env, _ := p["env"].(map[string]any); env["BROTHER_STORE"] != "${dir:state}/store.json" {
|
||||
t.Errorf("the device process's BROTHER_STORE = %v", env["BROTHER_STORE"])
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
if _, has := a["env"].(map[string]any)["BROTHER_STORE"]; has {
|
||||
t.Errorf("the tools' bundle is told where the bucket's key is")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1472: mesh-config matches a config value against its pattern whole, as mesh-sdk's catalogue compiles it
|
||||
// (`^(?:<pattern>)$`, go/catalogue/config.go). Each value the operator gives on a real node passes, and a value
|
||||
// of the wrong form does not.
|
||||
func TestEachConfigPatternAdmitsARealValueWhole(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config map[string]struct {
|
||||
Pattern string `json:"pattern"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]struct{ good, bad string }{
|
||||
"scanner": {"http://192.0.2.10/eSCL", "192.0.2.10"},
|
||||
"scan-folder": {"/home/operator/Documents/Scans", "Documents/Scans"},
|
||||
"printer": {"192.0.2.10", "http://192.0.2.10"},
|
||||
"printer-certificate": {"EA:B4:B7:0F:96:CD:A6:2B:61:53:2E:F0:A8:D6:9F:42:EA:9F:66:0A:82:5B:C2:43:0B:97:C5:9A:7A:B4:18:5B", "EAB4"},
|
||||
}
|
||||
for key, c := range cases {
|
||||
p := m.Config[key].Pattern
|
||||
if p == "" {
|
||||
t.Errorf("%s declares no pattern", key)
|
||||
continue
|
||||
}
|
||||
re := regexp.MustCompile(`^(?:` + p + `)$`)
|
||||
if !re.MatchString(c.good) {
|
||||
t.Errorf("%s's pattern %q refuses the real value %q", key, p, c.good)
|
||||
}
|
||||
if re.MatchString(c.bad) {
|
||||
t.Errorf("%s's pattern %q admits %q", key, p, c.bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
// The module's bucket (novox/hq ADR 0468 clause 2): each scan's PDF kept once, by its SHA-256, and each upload
|
||||
// under uploads/, in the bucket the `minio` module provides this module through the `s3-bucket` provision. The
|
||||
// mesh writes the bucket's facts and its key into one file, store.json, which only the device process (account
|
||||
// brother-device) can read (BROTHER_STORE); this speaks S3 to it with the standard library alone, as mesh-mail's
|
||||
// store does (modules/mesh-mail/cmd/mesh-mail/objects.go): a HEAD, a PUT and a GET, each signed with AWS
|
||||
// Signature Version 4, path-style.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Objects is the module's bucket.
|
||||
type Objects interface {
|
||||
// Bucket is the bucket's name, as an event names it.
|
||||
Bucket() string
|
||||
// Has says whether the key is kept.
|
||||
Has(ctx context.Context, key string) (bool, error)
|
||||
// Put keeps the bytes under the key.
|
||||
Put(ctx context.Context, key, mediaType string, data []byte) error
|
||||
// Get is the bytes kept under the key.
|
||||
Get(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Store keeps the bytes under the key once: a key already kept is left as it is, since a key that names the
|
||||
// content (the SHA-256 of a scan) names the same bytes.
|
||||
func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) error {
|
||||
has, err := o.Has(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
return o.Put(ctx, key, mediaType, data)
|
||||
}
|
||||
|
||||
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
|
||||
type StoreFacts struct {
|
||||
Scheme string `json:"scheme"`
|
||||
At string `json:"at"`
|
||||
Port string `json:"port"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// ReadStoreFacts reads the store's facts.
|
||||
func ReadStoreFacts(path string) (StoreFacts, error) {
|
||||
var f StoreFacts
|
||||
if path == "" {
|
||||
return f, errors.New("the module was started without its store's facts (BROTHER_STORE)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("the store's facts cannot be read: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return f, fmt.Errorf("the store's facts are not the module's: %v", err)
|
||||
}
|
||||
if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" {
|
||||
return f, errors.New("the store's facts name no address, bucket or key")
|
||||
}
|
||||
if f.Scheme == "" {
|
||||
f.Scheme = "http"
|
||||
}
|
||||
if f.Region == "" {
|
||||
f.Region = "us-east-1"
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// S3 is the module's bucket over S3.
|
||||
type S3 struct {
|
||||
Endpoint string // scheme://host[:port]
|
||||
Name string
|
||||
Region string
|
||||
Access string
|
||||
Secret string
|
||||
Client *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewS3 is the store the facts name.
|
||||
func NewS3(f StoreFacts) *S3 {
|
||||
host := f.At
|
||||
if f.Port != "" {
|
||||
host += ":" + f.Port
|
||||
}
|
||||
return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret,
|
||||
Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now}
|
||||
}
|
||||
|
||||
func (s *S3) Bucket() string { return s.Name }
|
||||
|
||||
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
|
||||
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mediaType != "" {
|
||||
req.Header.Set("Content-Type", mediaType)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC())
|
||||
return s.Client.Do(req)
|
||||
}
|
||||
|
||||
func (s *S3) Has(ctx context.Context, key string) (bool, error) {
|
||||
resp, err := s.do(ctx, http.MethodHead, key, "", nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the module's bucket cannot be reached: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("the module's bucket answered %s to asking for %s", resp.Status, key)
|
||||
}
|
||||
|
||||
func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error {
|
||||
resp, err := s.do(ctx, http.MethodPut, key, mediaType, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the module's bucket cannot be reached: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("the module's bucket refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
resp, err := s.do(ctx, http.MethodGet, key, "", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's bucket cannot be reached: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("the module's bucket answered %s for %s", resp.Status, key)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is
|
||||
// signed, with the payload's hash.
|
||||
func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) {
|
||||
payload := sha256.Sum256(body)
|
||||
payloadHex := hex.EncodeToString(payload[:])
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
day := now.Format("20060102")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHex)
|
||||
|
||||
headers := map[string]string{"host": req.URL.Host}
|
||||
for k, v := range req.Header {
|
||||
lk := strings.ToLower(k)
|
||||
if lk == "user-agent" || lk == "content-length" || lk == "authorization" {
|
||||
continue
|
||||
}
|
||||
headers[lk] = strings.TrimSpace(strings.Join(v, ","))
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonHeaders strings.Builder
|
||||
for _, k := range names {
|
||||
canonHeaders.WriteString(k + ":" + headers[k] + "\n")
|
||||
}
|
||||
signed := strings.Join(names, ";")
|
||||
path := req.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n")
|
||||
scope := day + "/" + region + "/s3/aws4_request"
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:])
|
||||
key := hmacOf([]byte("AWS4"+secret), day)
|
||||
key = hmacOf(key, region)
|
||||
key = hmacOf(key, "s3")
|
||||
key = hmacOf(key, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacOf(key, toSign))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig)
|
||||
}
|
||||
|
||||
func canonicalQuery(q url.Values) string {
|
||||
keys := make([]string, 0, len(q))
|
||||
for k := range q {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var parts []string
|
||||
for _, k := range keys {
|
||||
vs := append([]string(nil), q[k]...)
|
||||
sort.Strings(vs)
|
||||
for _, v := range vs {
|
||||
parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20"))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func hmacOf(key []byte, data string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// memObjects is the bucket in memory, for the tests of what the device process keeps.
|
||||
type memObjects struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
puts int
|
||||
}
|
||||
|
||||
func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} }
|
||||
|
||||
func (m *memObjects) Bucket() string { return "brother-test" }
|
||||
|
||||
func (m *memObjects) Has(_ context.Context, key string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.kept[key]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.kept[key] = append([]byte(nil), data...)
|
||||
m.puts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
b, ok := m.kept[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not kept", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (m *memObjects) keys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var k []string
|
||||
for key := range m.kept {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// 1453/AC8: the S3 client puts, heads and gets path-style under a Signature Version 4 header, and Store keeps
|
||||
// a key once.
|
||||
func TestTheBucketIsSpokenToPathStyleSignedAndKeptOnce(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
objects := map[string][]byte{}
|
||||
var puts int
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
auth = r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(r.URL.Path, "/scans/") {
|
||||
http.Error(w, "path-style: /<bucket>/<key>", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/scans/")
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
objects[key] = b
|
||||
puts++
|
||||
case http.MethodHead, http.MethodGet:
|
||||
b, ok := objects[key]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
w.Write(b)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "http://")
|
||||
at, port, _ := strings.Cut(host, ":")
|
||||
s3 := NewS3(StoreFacts{Scheme: "http", At: at, Port: port, Bucket: "scans", Region: "us-east-1", Key: "AKID", Secret: "sek"})
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := Store(ctx, s3, "abc.pdf", "application/pdf", []byte("%PDF-x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if puts != 1 {
|
||||
t.Errorf("%d puts, want the one: a key is kept once", puts)
|
||||
}
|
||||
got, err := s3.Get(ctx, "abc.pdf")
|
||||
if err != nil || string(got) != "%PDF-x" {
|
||||
t.Errorf("Get = %q, %v", got, err)
|
||||
}
|
||||
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=AKID/") || !strings.Contains(auth, "SignedHeaders=") || strings.Contains(auth, "sek") {
|
||||
t.Errorf("Authorization = %q", auth)
|
||||
}
|
||||
if _, err := s3.Get(ctx, "none.pdf"); err == nil {
|
||||
t.Errorf("a key not kept was got")
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: the store's facts are read from store.json, and one that names no bucket or key is refused by name.
|
||||
func TestTheStoresFactsAreRead(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "store.json")
|
||||
os.WriteFile(p, []byte(`{"at":"minio.example","port":"9000","bucket":"brother","key":"k","secret":"s"}`), 0o600)
|
||||
f, err := ReadStoreFacts(p)
|
||||
if err != nil || f.Scheme != "http" || f.Region != "us-east-1" || f.Bucket != "brother" {
|
||||
t.Errorf("facts = %+v, %v", f, err)
|
||||
}
|
||||
os.WriteFile(p, []byte(`{"at":"x"}`), 0o600)
|
||||
if _, err := ReadStoreFacts(p); err == nil || !strings.Contains(err.Error(), "bucket") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
if _, err := ReadStoreFacts(""); err == nil || !strings.Contains(err.Error(), "BROTHER_STORE") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Documents cross the bus in parts (novox/hq ADR 0468 clause 3): the bus refuses a message over 1 MiB, and a
|
||||
// part travels base64-encoded inside a JSON message.
|
||||
const (
|
||||
// PartSize is how large the parts this module cuts are, and MaxPart the most raw bytes a part may hold; a part
|
||||
// of more is refused. They are one: 700 KiB, so that a part's base64 text (about 933 KB) and the message around
|
||||
// it stay under the bus's 1 MiB. (ADR 0468 clause 3.)
|
||||
PartSize = 700 << 10
|
||||
MaxPart = PartSize
|
||||
// AnswerPartSize is the raw size of a part the device process answers in the state device-answers
|
||||
// (scanner.document). The state refuses a value over 256 KiB (the mesh's cap on every bucket's value), and a
|
||||
// part travels base64-encoded in JSON, so 128 KiB (about 175 KB written) fits with room to spare; a part of
|
||||
// 700 KiB (about 933 KB) can only cross the bus as a message, never as a state value (issue 1535).
|
||||
AnswerPartSize = 128 << 10
|
||||
// StateMaxValue is the most a state value may hold.
|
||||
StateMaxValue = 256 << 10
|
||||
// MostAnswerParts bounds how many answer parts a document has (a 100 MiB document is the most).
|
||||
MostAnswerParts = 800
|
||||
// MostParts bounds how many parts one document has (a 100 MiB document is the most).
|
||||
MostParts = 150
|
||||
)
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// partCount is how many parts of PartSize hold size bytes: at least one.
|
||||
func partCount(size int) int { return partsOf(size, PartSize) }
|
||||
|
||||
// answerPartCount is how many parts of AnswerPartSize hold size bytes: at least one.
|
||||
func answerPartCount(size int) int { return partsOf(size, AnswerPartSize) }
|
||||
|
||||
func partsOf(size, each int) int {
|
||||
if size <= 0 {
|
||||
return 1
|
||||
}
|
||||
return (size + each - 1) / each
|
||||
}
|
||||
|
||||
// cutPart is part number part (counted from 1) of data, in parts of AnswerPartSize, and how many parts there are.
|
||||
func cutPart(data []byte, part int) ([]byte, int, error) {
|
||||
return cutPartOf(data, part, AnswerPartSize)
|
||||
}
|
||||
|
||||
func cutPartOf(data []byte, part, each int) ([]byte, int, error) {
|
||||
n := partsOf(len(data), each)
|
||||
if part < 1 || part > n {
|
||||
return nil, n, fmt.Errorf("part %d is out of range: the document has parts 1 to %d", part, n)
|
||||
}
|
||||
lo := (part - 1) * each
|
||||
hi := min(lo+each, len(data))
|
||||
return data[lo:hi], n, nil
|
||||
}
|
||||
|
||||
// joinParts is the parts in order as one document, once its SHA-256 is the one named.
|
||||
func joinParts(parts [][]byte, wantSHA string) ([]byte, error) {
|
||||
var n int
|
||||
for _, p := range parts {
|
||||
n += len(p)
|
||||
}
|
||||
out := make([]byte, 0, n)
|
||||
for _, p := range parts {
|
||||
out = append(out, p...)
|
||||
}
|
||||
if got := sha256Hex(out); got != wantSHA {
|
||||
return nil, fmt.Errorf("the joined parts' SHA-256 is %s, not the %s named: refused", got, wantSHA)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
)
|
||||
|
||||
// PDF writes a one-page PDF holding the JPEG as it is (DCTDecode), the page as large as the image really is:
|
||||
// pixels over dpi, in points (72 to the inch).
|
||||
func PDF(jpegBytes []byte, dpi int) ([]byte, error) {
|
||||
if dpi <= 0 {
|
||||
return nil, fmt.Errorf("resolution %d dpi is no resolution", dpi)
|
||||
}
|
||||
cfg, err := jpeg.DecodeConfig(bytes.NewReader(jpegBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the image cannot be read: %w", err)
|
||||
}
|
||||
space := "DeviceRGB"
|
||||
if cfg.ColorModel == color.GrayModel {
|
||||
space = "DeviceGray"
|
||||
}
|
||||
wpt := float64(cfg.Width) / float64(dpi) * 72
|
||||
hpt := float64(cfg.Height) / float64(dpi) * 72
|
||||
content := fmt.Sprintf("q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q\n", wpt, hpt)
|
||||
|
||||
var b bytes.Buffer
|
||||
var offsets []int
|
||||
obj := func(body string) {
|
||||
offsets = append(offsets, b.Len())
|
||||
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body)
|
||||
}
|
||||
b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")
|
||||
obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||
obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>", wpt, hpt))
|
||||
offsets = append(offsets, b.Len())
|
||||
fmt.Fprintf(&b, "4 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /%s /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n",
|
||||
cfg.Width, cfg.Height, space, len(jpegBytes))
|
||||
b.Write(jpegBytes)
|
||||
b.WriteString("\nendstream\nendobj\n")
|
||||
obj(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content))
|
||||
xref := b.Len()
|
||||
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
|
||||
for _, o := range offsets {
|
||||
fmt.Fprintf(&b, "%010d 00000 n \n", o)
|
||||
}
|
||||
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fingerprint is the SHA-256 of a certificate's DER bytes, as upper-case hex pairs joined by colons: the
|
||||
// form the printer's own certificate page and the config value printer-certificate give (ADR 0467 clause 3).
|
||||
func Fingerprint(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
parts := make([]string, len(sum))
|
||||
for i, b := range sum {
|
||||
parts[i] = fmt.Sprintf("%02X", b)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// pinList splits the config value printer-certificate into its fingerprints: one or more, separated by commas
|
||||
// with optional spaces, upper-cased. The printer serves two certificates for one name, an ECDSA one and an RSA
|
||||
// one, and which it serves depends on the client, so the operator pins both (issue 1503).
|
||||
func pinList(value string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(value, ",") {
|
||||
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be one of those pinned,
|
||||
// and any other certificate is refused, with the served and every pinned fingerprint in the message. It runs in
|
||||
// the handshake, so a refused certificate is refused before one byte of a request (a password) is sent.
|
||||
func pinned(value string) func([][]byte, [][]*x509.Certificate) error {
|
||||
want := pinList(value)
|
||||
list := strings.Join(want, ", ")
|
||||
return func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", list)
|
||||
}
|
||||
got := Fingerprint(raw[0])
|
||||
for _, w := range want {
|
||||
if got == w {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, list)
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedTLS is the TLS configuration for the printer's admin page: the printer's certificate is self-signed,
|
||||
// so no chain is verified; the pinned fingerprint is the whole of the trust (ADR 0467 clause 3).
|
||||
func pinnedTLS(fingerprint string) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// The chain is not verified because the pinned fingerprint is checked in its place, below.
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: pinned(fingerprint),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Captured from the printer 2026-10-20 (testdata/pin): it serves two self-signed certificates for one name, an
|
||||
// ECDSA one to a client offering ECDSA and an RSA one to a client offering RSA only (issue 1503).
|
||||
const (
|
||||
printerECDSAPrint = "EA:B4:B7:0F:96:CD:A6:2B:61:53:2E:F0:A8:D6:9F:42:EA:9F:66:0A:82:5B:C2:43:0B:97:C5:9A:7A:B4:18:5B"
|
||||
printerRSAPrint = "7A:3D:E2:A4:1E:AB:A9:7B:E6:61:0A:C4:18:DD:68:FF:9B:A5:77:E8:02:7F:7E:F8:97:D0:3B:82:A2:89:7D:F4"
|
||||
)
|
||||
|
||||
func capturedDER(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/pin/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := pem.Decode(raw)
|
||||
if b == nil {
|
||||
t.Fatalf("%s is not PEM", name)
|
||||
}
|
||||
return b.Bytes
|
||||
}
|
||||
|
||||
// 1503/AC1: the two captured certificates have the two fingerprints, and a pin listing both accepts each.
|
||||
func TestThePrintersTwoCapturedCertificatesAreBothAcceptedByAPinListingBoth(t *testing.T) {
|
||||
both := printerECDSAPrint + ", " + printerRSAPrint
|
||||
for file, want := range map[string]string{"ecdsa.pem": printerECDSAPrint, "rsa.pem": printerRSAPrint} {
|
||||
der := capturedDER(t, file)
|
||||
if got := Fingerprint(der); got != want {
|
||||
t.Fatalf("%s has fingerprint %s, want %s", file, got, want)
|
||||
}
|
||||
if err := pinned(both)([][]byte{der}, nil); err != nil {
|
||||
t.Errorf("%s refused by a pin listing both: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1503/AC1: a server presenting one certificate is accepted when the pin lists its fingerprint among two.
|
||||
func TestAPinListingTwoFingerprintsAcceptsTheCertificateTheServerPresents(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "hunter2")
|
||||
a, err := NewAdmin(f.host(), printerECDSAPrint+","+f.print(), "hunter2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Login(context.Background()); err != nil {
|
||||
t.Fatalf("a pin listing the served certificate was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1503/AC2: a pin that lists only another certificate still refuses, and names every pinned fingerprint.
|
||||
func TestAPinListingOnlyOtherCertificatesStillRefuses(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "hunter2")
|
||||
a, _ := NewAdmin(f.host(), printerECDSAPrint+" , "+printerRSAPrint, "hunter2")
|
||||
err := a.Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("a certificate that is in no pinned fingerprint was accepted")
|
||||
}
|
||||
for _, want := range []string{f.print(), printerECDSAPrint, printerRSAPrint} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not name %s", err, want)
|
||||
}
|
||||
}
|
||||
if len(f.requests) != 0 {
|
||||
t.Errorf("the server was sent %v", f.requests)
|
||||
}
|
||||
}
|
||||
|
||||
// 1503/AC1: a single fingerprint is still a valid pin, in either case.
|
||||
func TestASingleFingerprintStillPins(t *testing.T) {
|
||||
der := capturedDER(t, "rsa.pem")
|
||||
for _, p := range []string{printerRSAPrint, strings.ToLower(printerRSAPrint), " " + printerRSAPrint + " "} {
|
||||
if err := pinned(p)([][]byte{der}, nil); err != nil {
|
||||
t.Errorf("pin %q refused: %v", p, err)
|
||||
}
|
||||
}
|
||||
if err := pinned(printerECDSAPrint)([][]byte{der}, nil); err == nil {
|
||||
t.Error("the other certificate's pin accepted the RSA certificate")
|
||||
}
|
||||
}
|
||||
|
||||
// 1503/AC3: the manifest's pattern takes one or more fingerprints, separated by commas and optional spaces, and
|
||||
// nothing else.
|
||||
func TestThePrinterCertificatePatternTakesACommaSeparatedList(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
re := regexp.MustCompile(`^(?:` + m.Config["printer-certificate"]["pattern"].(string) + `)$`)
|
||||
for _, ok := range []string{printerRSAPrint, printerECDSAPrint + "," + printerRSAPrint, printerECDSAPrint + ", " + printerRSAPrint} {
|
||||
if !re.MatchString(ok) {
|
||||
t.Errorf("pattern refuses %q", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", printerRSAPrint + ",", "," + printerRSAPrint, printerECDSAPrint + printerRSAPrint,
|
||||
printerECDSAPrint + ",," + printerRSAPrint, printerECDSAPrint + ";" + printerRSAPrint, printerRSAPrint + ",EAB4", strings.ToLower(printerRSAPrint)} {
|
||||
if re.MatchString(bad) {
|
||||
t.Errorf("pattern admits %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in
|
||||
// .html, with no query, no dot-dot and no second slash at its start (which would name another host).
|
||||
var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`)
|
||||
|
||||
// DefaultPage is the page printer.settings reads when none is asked for.
|
||||
const DefaultPage = "/home/status.html"
|
||||
|
||||
// The asks the verbs put to the device process (ADR 0468 clause 1; the shape of ADR 0436's mail-sends): the tools
|
||||
// never hold a credential, so each verb queues an ask on the work-queue stream device-asks, and the device process,
|
||||
// an account of its own, answers under the ask's id in the state device-answers.
|
||||
const (
|
||||
// EventAsked is the event on the work queue; the stream device-asks carries it.
|
||||
EventAsked = "device.asked"
|
||||
// AsksStream is the work-queue stream's name on the bus: the manifest's local name device-asks, capitalised
|
||||
// with its hyphen an underscore, as the controller composes a stream's own bus name.
|
||||
AsksStream = "DEVICE_ASKS"
|
||||
// AnswersState is the state the device process writes its answers in.
|
||||
AnswersState = "device-answers"
|
||||
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
|
||||
// delivers its bus account as the own secret broker-device.
|
||||
DeviceProcessID = "device"
|
||||
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
|
||||
AnswerWait = 60 * time.Second
|
||||
ScanWait = 180 * time.Second
|
||||
// StaleAfter is how old an ask may be when the device process takes it: older than twice the longest wait,
|
||||
// nobody waits for it any more, so the device is not touched for it.
|
||||
StaleAfter = 2 * ScanWait
|
||||
)
|
||||
|
||||
// The operations of an ask.
|
||||
const (
|
||||
OpSettings = "settings"
|
||||
OpScan = "scan"
|
||||
OpDocument = "document"
|
||||
OpUpload = "upload"
|
||||
OpPrint = "print"
|
||||
)
|
||||
|
||||
// Ask is what a verb queues: the operation, when it was asked, and the operation's own arguments.
|
||||
type Ask struct {
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"`
|
||||
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
|
||||
Parts int `json:"parts,omitempty"` // upload
|
||||
SHA256 string `json:"sha256,omitempty"` // upload: the whole document's
|
||||
Data string `json:"data,omitempty"` // upload: the part, base64
|
||||
Copies int `json:"copies,omitempty"` // print
|
||||
Sides string `json:"sides,omitempty"` // print
|
||||
Media string `json:"media,omitempty"` // print
|
||||
}
|
||||
|
||||
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
|
||||
type AskBus interface {
|
||||
Publish(id string, body any) error
|
||||
Answer(id string) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type sdkAskBus struct{}
|
||||
|
||||
func (sdkAskBus) Publish(id string, body any) error {
|
||||
return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id})
|
||||
}
|
||||
|
||||
func (sdkAskBus) Answer(id string) (json.RawMessage, error) {
|
||||
e, err := stdio.State(AnswersState).Get(id)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}
|
||||
|
||||
// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them.
|
||||
var (
|
||||
answerWait = AnswerWait
|
||||
scanWait = ScanWait
|
||||
answerPoll = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// askBus is the bus the tools use; a test puts a bus of its own in its place.
|
||||
var askBus AskBus = sdkAskBus{}
|
||||
|
||||
func newAskID() string {
|
||||
b := make([]byte, 12)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// call queues the ask and waits for the device process's answer, at most wait. An answer that is an error is
|
||||
// the error. Nothing here reads, opens or sends a credential (ADR 0436 §12).
|
||||
func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[string]any, error) {
|
||||
poll := s.AnswerPoll
|
||||
if wait <= 0 {
|
||||
wait = AnswerWait
|
||||
}
|
||||
if poll <= 0 {
|
||||
poll = 500 * time.Millisecond
|
||||
}
|
||||
ask.ID, ask.At = newAskID(), s.Now().UTC()
|
||||
if err := s.Asks.Publish(ask.ID, ask); err != nil {
|
||||
return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, wait)
|
||||
defer cancel()
|
||||
for {
|
||||
raw, err := s.Asks.Answer(ask.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
|
||||
}
|
||||
if raw != nil {
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
|
||||
}
|
||||
if msg, bad := out["error"].(string); bad {
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the device is slow (ask %s)",
|
||||
wait, ask.ID)
|
||||
case <-time.After(poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
|
||||
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
|
||||
func (s *Service) PrinterSettings(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
|
||||
if page == "" {
|
||||
page = DefaultPage
|
||||
}
|
||||
if !validPage(page) {
|
||||
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
|
||||
}
|
||||
if err := s.Settings.MissingPrinter(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.call(ctx, Ask{Op: OpSettings, Page: page, Links: links, Hidden: hidden}, s.AnswerWait)
|
||||
}
|
||||
|
||||
func validPage(page string) bool {
|
||||
return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//")
|
||||
}
|
||||
|
||||
var (
|
||||
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
|
||||
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
// Sides are the values the printer's sides take.
|
||||
var sidesValues = []string{"one-sided", "two-sided-long-edge", "two-sided-short-edge"}
|
||||
|
||||
// DefaultMedia is the paper printer.print takes when none is named.
|
||||
const DefaultMedia = "iso_a4_210x297mm"
|
||||
|
||||
// checkDocument is what is wrong with an ask for a scan's part, or nil.
|
||||
func checkDocument(a Ask) error {
|
||||
if !scanKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
||||
}
|
||||
if a.Part < 1 {
|
||||
return fmt.Errorf("part is a whole number from 1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUpload is what is wrong with an ask for an upload's part, or nil: the numbers, the SHA-256, the size.
|
||||
func checkUpload(a Ask) error {
|
||||
if !uploadPattern.MatchString(a.Upload) {
|
||||
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
||||
}
|
||||
if a.Parts < 1 || a.Parts > MostParts {
|
||||
return fmt.Errorf("parts is a whole number from 1 to %d, not %d", MostParts, a.Parts)
|
||||
}
|
||||
if a.Part < 1 || a.Part > a.Parts {
|
||||
return fmt.Errorf("part %d is out of range: the upload has parts 1 to %d", a.Part, a.Parts)
|
||||
}
|
||||
if !sha256Pattern.MatchString(a.SHA256) {
|
||||
return fmt.Errorf("sha256 is the whole document's SHA-256, 64 lower-case hex digits")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(a.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("data is not base64: %v", err)
|
||||
}
|
||||
if len(raw) > MaxPart {
|
||||
return fmt.Errorf("a part is at most %d bytes (700 KiB), not %d", MaxPart, len(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
|
||||
func checkPrint(a *Ask) error {
|
||||
if (a.Object == "") == (a.Upload == "") {
|
||||
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
|
||||
}
|
||||
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
||||
}
|
||||
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
|
||||
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
||||
}
|
||||
if a.Copies == 0 {
|
||||
a.Copies = 1
|
||||
}
|
||||
if a.Copies < 1 || a.Copies > 99 {
|
||||
return fmt.Errorf("copies is a whole number from 1 to 99, not %d", a.Copies)
|
||||
}
|
||||
if a.Sides == "" {
|
||||
a.Sides = "one-sided"
|
||||
}
|
||||
if !slices.Contains(sidesValues, a.Sides) {
|
||||
return fmt.Errorf("sides is one of %s, not %q", strings.Join(sidesValues, ", "), a.Sides)
|
||||
}
|
||||
if a.Media == "" {
|
||||
a.Media = DefaultMedia
|
||||
}
|
||||
if !mediaPattern.MatchString(a.Media) {
|
||||
return fmt.Errorf("media %q is not a media size name such as %s", a.Media, DefaultMedia)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScanDocument asks for one part of a scan kept in the bucket.
|
||||
func (s *Service) ScanDocument(ctx context.Context, object string, part int) (map[string]any, error) {
|
||||
a := Ask{Op: OpDocument, Object: object, Part: part}
|
||||
if err := checkDocument(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
}
|
||||
|
||||
// PrinterUpload sends one part of a document to be printed later, and says whether the upload is complete.
|
||||
func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, error) {
|
||||
a.Op = OpUpload
|
||||
if err := checkUpload(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
}
|
||||
|
||||
// PrinterPrint asks the device process to print a kept scan or a completed upload.
|
||||
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
|
||||
a.Op = OpPrint
|
||||
if err := checkPrint(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Settings.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
}
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
}
|
||||
|
||||
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
|
||||
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
|
||||
// scanner is not set is said by name, and nothing is contacted.
|
||||
func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) {
|
||||
var unset []string
|
||||
if s.Settings.Printer == "" {
|
||||
unset = append(unset, "printer")
|
||||
}
|
||||
if s.Settings.Scanner == "" {
|
||||
unset = append(unset, "scanner")
|
||||
}
|
||||
if len(unset) > 0 {
|
||||
return nil, unsetError(unset...)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil {
|
||||
out["answers"] = false
|
||||
out["why"] = err.Error()
|
||||
} else {
|
||||
out["answers"] = true
|
||||
out["state"] = st.State
|
||||
out["state-code"] = st.StateCode
|
||||
out["state-reasons"] = st.Reasons
|
||||
out["make-and-model"] = st.MakeAndModel
|
||||
out["markers"] = st.Markers
|
||||
}
|
||||
if es, err := NewClient(s.Settings.Scanner).Status(ctx); err != nil {
|
||||
out["scanner"] = map[string]any{"answers": false, "why": err.Error()}
|
||||
} else {
|
||||
out["scanner"] = map[string]any{"answers": true, "state": es.State, "adf-state": es.AdfState}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"brother/cmd/brother/internal/secret"
|
||||
)
|
||||
|
||||
// 1453/AC3: printer.settings logs in by the field name the form gives, sends the password from the secret
|
||||
// file, follows the session cookie, and returns the page's title, links and form fields; a password field's
|
||||
// value is never returned, and the session is logged out. The page is SYNTHETIC (testdata/admin/
|
||||
// synthetic-settings.html): the real pages arrive in round 2.
|
||||
func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) {
|
||||
f := newFakePrinter(t, "Qw9z3", "p4ss word")
|
||||
_, run := printerTools(t, f, "p4ss word")
|
||||
got, err := run(map[string]any{"page": "/general/other.html"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.posted) != 1 || f.posted[0].Get("Qw9z3") != "p4ss word" {
|
||||
t.Errorf("the login posted %v", f.posted)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
out := string(raw)
|
||||
var a struct {
|
||||
Title string
|
||||
Links []Link
|
||||
Forms []Form
|
||||
Logout string
|
||||
}
|
||||
json.Unmarshal(raw, &a)
|
||||
if a.Title != "Synthetic settings" {
|
||||
t.Errorf("title = %q", a.Title)
|
||||
}
|
||||
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)
|
||||
}
|
||||
want := map[string]Field{
|
||||
"lock": {Name: "lock", Type: "checkbox", Value: "on", Label: "Scan lock"},
|
||||
"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"},
|
||||
}
|
||||
for _, fl := range a.Forms[0].Fields {
|
||||
if fl.Name == "mode" {
|
||||
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
|
||||
}
|
||||
w, ok := want[fl.Name]
|
||||
if !ok || fl.Name != w.Name || fl.Type != w.Type || fl.Value != w.Value || fl.Label != w.Label {
|
||||
t.Errorf("field %+v, want %+v", fl, w)
|
||||
}
|
||||
delete(want, fl.Name)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Errorf("fields missing: %v", want)
|
||||
}
|
||||
if strings.Contains(out, "shouldnotshow") {
|
||||
t.Errorf("a password field's value was returned: %s", out)
|
||||
}
|
||||
if a.Logout != "logged out" || f.loggedInNow() != 0 {
|
||||
t.Errorf("logout = %q, sessions open %d", a.Logout, f.loggedInNow())
|
||||
}
|
||||
// Read-only: the only form posted is the login.
|
||||
if len(f.posted) != 1 {
|
||||
t.Errorf("forms posted: %v", f.posted)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC3: with no page asked for, the status page is read.
|
||||
func TestPrinterSettingsDefaultsToTheStatusPage(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
_, run := printerTools(t, f, "pw")
|
||||
got, err := run(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.(map[string]any)["title"] != "Synthetic status" {
|
||||
t.Errorf("answer = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4: a wrong password is said as a failed login.
|
||||
func TestPrinterSettingsSaysAFailedLogin(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "right")
|
||||
_, run := printerTools(t, f, "wrong")
|
||||
if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "login failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4: printer or printer-certificate not set is said by name by the tool, nothing is queued and the
|
||||
// printer is not contacted.
|
||||
func TestPrinterSettingsSaysWhichConfigIsUnsetAndQueuesNothing(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
for _, c := range []struct{ name, settings, want string }{
|
||||
{"no printer", `{"printer-certificate":"` + f.print() + `"}`, "printer is not set"},
|
||||
{"no certificate", `{"printer":"` + f.host() + `"}`, "printer-certificate is not set"},
|
||||
{"neither", `{}`, "printer and printer-certificate"},
|
||||
} {
|
||||
r := newRig(t, c.settings, "pw")
|
||||
_, err := r.run(nil)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: error = %v, want it to say %q", c.name, err, c.want)
|
||||
}
|
||||
if r.bus.asks() != 0 {
|
||||
t.Errorf("%s: an ask was queued", c.name)
|
||||
}
|
||||
}
|
||||
if f.connsNow() != 0 || len(f.requests) != 0 {
|
||||
t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4: the secret not given is said by name, by the device process, and the printer is not contacted.
|
||||
func TestPrinterSettingsSaysTheSecretIsNotGivenAndContactsNothing(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
_, run := printerTools(t, f, "")
|
||||
if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
|
||||
t.Errorf("no secret file: error = %v", err)
|
||||
}
|
||||
// An empty file is not given either.
|
||||
r := newRig(t, printerSettingsJSON(f), "")
|
||||
r.worker.Password = func() (string, error) {
|
||||
p := filepath.Join(t.TempDir(), "e")
|
||||
os.WriteFile(p, []byte("\n"), 0o600)
|
||||
return secret.Read(p)
|
||||
}
|
||||
if _, err := r.run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") {
|
||||
t.Errorf("empty secret: error = %v", err)
|
||||
}
|
||||
if f.connsNow() != 0 || len(f.requests) != 0 {
|
||||
t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: a page that is not a page of the admin site is refused before anything is queued, and by the admin
|
||||
// process too if one reaches it.
|
||||
func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
r := newRig(t, printerSettingsJSON(f), "pw")
|
||||
for _, p := range []string{"general/status.html", "/general/status", "//evil.example/x.html", "/a/../b.html", "/a.html?x=1", "/a b.html"} {
|
||||
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, false, false); err == nil || !strings.Contains(err.Error(), "not a page") {
|
||||
t.Errorf("the device process, page %q: error = %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.run(map[string]any{"page": 5}); err == nil {
|
||||
t.Errorf("a page that is no string was accepted")
|
||||
}
|
||||
if r.bus.asks() != 0 || f.connsNow() != 0 {
|
||||
t.Errorf("%d asks queued, the printer contacted %d times", r.bus.asks(), f.connsNow())
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: nobody consuming the queue is said as the device process not answering, naming the wait.
|
||||
func TestPrinterSettingsSaysWhenTheAdminProcessDoesNotAnswer(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
r := newRig(t, printerSettingsJSON(f), "pw")
|
||||
r.bus.worker = nil
|
||||
answerWait = 100 * time.Millisecond
|
||||
_, err := r.run(nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "device process did not answer") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if r.bus.asks() != 1 {
|
||||
t.Errorf("%d asks queued", r.bus.asks())
|
||||
}
|
||||
}
|
||||
|
||||
// 1453: the device process terminates an ask it cannot read, drops one nobody waits for any more without
|
||||
// contacting the printer, acknowledges one already answered, and asks for redelivery when it cannot write its
|
||||
// answer.
|
||||
func TestTheAdminWorkerSettlesEachAsk(t *testing.T) {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
r := newRig(t, printerSettingsJSON(f), "pw")
|
||||
w := r.worker
|
||||
if act, _ := w.Handle([]byte("not json"), nil); act != actionTerm {
|
||||
t.Errorf("an unreadable ask: action %v, want term", act)
|
||||
}
|
||||
old, _ := json.Marshal(Ask{ID: "old", Op: OpSettings, Page: DefaultPage, At: fixedNow.Add(-StaleAfter - time.Second)})
|
||||
if act, _ := w.Handle(old, nil); act != actionAck || r.answers.get("old") != nil || f.connsNow() != 0 {
|
||||
t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.connsNow())
|
||||
}
|
||||
fresh, _ := json.Marshal(Ask{ID: "a1", Op: OpSettings, Page: DefaultPage, At: fixedNow})
|
||||
if act, _ := w.Handle(fresh, nil); act != actionAck || r.answers.get("a1") == nil {
|
||||
t.Errorf("a fresh ask: action %v", act)
|
||||
}
|
||||
conns := f.connsNow()
|
||||
if act, _ := w.Handle(fresh, nil); act != actionAck {
|
||||
t.Errorf("an ask already answered: action %v, want ack", act)
|
||||
}
|
||||
_ = conns
|
||||
r.answers.fail = fmt.Errorf("the bus is away")
|
||||
again, _ := json.Marshal(Ask{ID: "a2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
|
||||
if act, delay := w.Handle(again, nil); act != actionRetry || delay <= 0 {
|
||||
t.Errorf("an answer that cannot be written for want of the bus: action %v delay %v, want retry", act, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 1514: printer.settings with no page, and with /home/status.html, logs in from the page the printer serves its
|
||||
// login form on (the root redirects to it) and reads that page. The printer has no /general/status.html behind
|
||||
// the login: the fake, like the captured printer, answers it a 404 once logged in, and a redirect to the login
|
||||
// page before.
|
||||
func TestPrinterSettingsDefaultPageAndHomeStatusAreReadAfterTheLogin(t *testing.T) {
|
||||
for name, args := range map[string]map[string]any{"default": {}, "explicit": {"page": "/home/status.html"}} {
|
||||
f := newFakePrinter(t, "B1891", "pw")
|
||||
_, run := printerTools(t, f, "pw")
|
||||
got, err := run(args)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
if !strings.Contains(string(raw), "Synthetic status") || !strings.Contains(string(raw), "/home/status.html") {
|
||||
t.Errorf("%s: answer %.200s", name, raw)
|
||||
}
|
||||
if len(f.posted) != 1 || f.posted[0].Get("loginurl") != "/home/status.html" {
|
||||
t.Errorf("%s: posted %v, want loginurl /home/status.html", name, f.posted)
|
||||
}
|
||||
if f.loggedInNow() != 0 {
|
||||
t.Errorf("%s: the session is still open", name)
|
||||
}
|
||||
}
|
||||
if DefaultPage != "/home/status.html" {
|
||||
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) != "CSRFToken,B1892,pageid,CSRFToken,postif_registration_reject,B117d" {
|
||||
t.Errorf("hidden:true fields = %s", names(more))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"brother/cmd/brother/internal/secret"
|
||||
)
|
||||
|
||||
// fixedNow is the time the tools and the device worker of a rig read.
|
||||
var fixedNow = time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local)
|
||||
|
||||
// memAnswers is the state device-answers, in memory.
|
||||
type memAnswers struct {
|
||||
mu sync.Mutex
|
||||
m map[string]json.RawMessage
|
||||
// fail, when set, is the error every Create answers.
|
||||
fail error
|
||||
}
|
||||
|
||||
func (a *memAnswers) Create(key string, v any) (uint64, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fail != nil {
|
||||
return 0, a.fail
|
||||
}
|
||||
if _, ok := a.m[key]; ok {
|
||||
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
|
||||
}
|
||||
if a.m == nil {
|
||||
a.m = map[string]json.RawMessage{}
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
if len(raw) > StateMaxValue {
|
||||
return 0, fmt.Errorf("brother's state device-answers, key %s: nats: %w", key, &jetstream.APIError{Code: 400, ErrorCode: jetstream.ErrorCode(10054), Description: "message size exceeds maximum allowed"})
|
||||
}
|
||||
a.m[key] = raw
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (a *memAnswers) get(key string) json.RawMessage {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.m[key]
|
||||
}
|
||||
|
||||
// fakeAskBus is the bus between the tools and the device process: an ask published on it is handed to the
|
||||
// worker, as the work queue hands it to the device process, and the worker's answers are what the tools read.
|
||||
type fakeAskBus struct {
|
||||
worker *deviceWorker // nil: nobody consumes the queue
|
||||
answers *memAnswers
|
||||
mu sync.Mutex
|
||||
published []Ask
|
||||
}
|
||||
|
||||
func (b *fakeAskBus) Publish(id string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
if len(raw) > 1<<20 {
|
||||
return fmt.Errorf("a message of %d bytes is over the bus's 1 MiB", len(raw))
|
||||
}
|
||||
var ask Ask
|
||||
json.Unmarshal(raw, &ask)
|
||||
b.mu.Lock()
|
||||
b.published = append(b.published, ask)
|
||||
b.mu.Unlock()
|
||||
if b.worker != nil {
|
||||
go b.worker.Handle(raw, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil }
|
||||
|
||||
func (b *fakeAskBus) asks() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.published)
|
||||
}
|
||||
|
||||
func (b *fakeAskBus) ops() []string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var o []string
|
||||
for _, a := range b.published {
|
||||
o = append(o, a.Op)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// printerRig is the tools, the bus and the device worker over a settings file, with the secret in a file the way
|
||||
// the mesh writes it (a trailing newline included; an empty password writes none) and the bucket in memory.
|
||||
type printerRig struct {
|
||||
tl []stdio.Tool
|
||||
run func(args map[string]any) (any, error) // printer.settings
|
||||
bus *fakeAskBus
|
||||
worker *deviceWorker
|
||||
answers *memAnswers
|
||||
objects *memObjects
|
||||
said *[]said
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
// call runs a tool by name.
|
||||
func (r *printerRig) call(t *testing.T, name string, args map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
return tool(t, r.tl, name).Run(args)
|
||||
}
|
||||
|
||||
func newRig(t *testing.T, settings, password string) *printerRig {
|
||||
t.Helper()
|
||||
secretFile := filepath.Join(t.TempDir(), "admin-password.secret")
|
||||
if password != "" {
|
||||
if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
file := settingsFile(t, settings)
|
||||
answers, objects := &memAnswers{}, newMemObjects()
|
||||
var out []said
|
||||
var mu sync.Mutex
|
||||
w := &deviceWorker{
|
||||
Settings: func() (Settings, error) { return LoadSettings(file) },
|
||||
Password: func() (string, error) { return secret.Read(secretFile) },
|
||||
Objects: func() (Objects, error) { return objects, nil },
|
||||
Emit: func(body map[string]any, id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_, err := objects.Get(t.Context(), body["object"].(string))
|
||||
out = append(out, said{body, id, err == nil})
|
||||
return nil
|
||||
},
|
||||
Answers: answers,
|
||||
Node: "shanks",
|
||||
Now: func() time.Time { return fixedNow },
|
||||
}
|
||||
bus := &fakeAskBus{worker: w, answers: answers}
|
||||
old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll
|
||||
askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond
|
||||
t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll })
|
||||
tl := tools(file, func() time.Time { return fixedNow })
|
||||
return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu}
|
||||
}
|
||||
|
||||
func printerSettingsJSON(f *fakePrinter) string {
|
||||
return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`
|
||||
}
|
||||
|
||||
func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) {
|
||||
t.Helper()
|
||||
js := printerSettingsJSON(f)
|
||||
return js, newRig(t, js, password).run
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device
|
||||
// process. Consumers declare brother.scan.added; it
|
||||
// travels as mesh.mod.brother.event.scan.added. It names the object and never carries the image.
|
||||
const EventAdded = "scan.added"
|
||||
|
||||
// Service is what the tools do, over one reading of the settings. The device process's own scan is Capture's.
|
||||
type Service struct {
|
||||
Settings Settings
|
||||
// Node is this machine's name, for the event.
|
||||
Node string
|
||||
Now func() time.Time
|
||||
// Asks is how the verbs reach the device process, which alone holds a credential.
|
||||
Asks AskBus
|
||||
// AnswerWait is how long a verb waits for the device process's answer (ScanWait for a scan); AnswerPoll how
|
||||
// often it looks.
|
||||
AnswerWait, ScanWait, AnswerPoll time.Duration
|
||||
}
|
||||
|
||||
// ScanNow asks the device process to scan, crop, build the PDF, keep it in the bucket and say scan.added, waits for
|
||||
// the stored scan's key, fetches its parts through the device process, joins them, checks their SHA-256 and files
|
||||
// the PDF in the scan folder (temp and rename, no overwrite). It answers the event's body and the path. Nothing is
|
||||
// asked before scanner and scan-folder are known to be set (1440/AC7); the tool never reads a credential.
|
||||
func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) {
|
||||
if err := s.Settings.Missing(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ans, err := s.call(ctx, Ask{Op: OpScan}, s.ScanWait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, _ := ans["key"].(string)
|
||||
sum, _ := ans["sha256"].(string)
|
||||
parts, _ := ans["parts"].(float64)
|
||||
if key == "" || sum == "" || parts < 1 || parts > MostAnswerParts {
|
||||
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan: %v", ans)
|
||||
}
|
||||
got := make([][]byte, 0, int(parts))
|
||||
for i := 1; i <= int(parts); i++ {
|
||||
p, err := s.fetchPart(ctx, key, i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but its part %d could not be fetched: %w", key, i, err)
|
||||
}
|
||||
got = append(got, p)
|
||||
}
|
||||
pdf, err := joinParts(got, sum)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s: %w", key, err)
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
|
||||
if err != nil {
|
||||
at = s.Now()
|
||||
}
|
||||
path, err := File(s.Settings.Folder, pdf, at.Local())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but could not be filed: %w", key, err)
|
||||
}
|
||||
body := map[string]any{}
|
||||
for k, v := range ans {
|
||||
if k != "key" && k != "size" && k != "parts" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
body["path"] = path
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchPart is one part of a stored object, asked of the device process and decoded.
|
||||
func (s *Service) fetchPart(ctx context.Context, object string, part int) ([]byte, error) {
|
||||
ans, err := s.call(ctx, Ask{Op: OpDocument, Object: object, Part: part}, s.AnswerWait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _ := ans["data"].(string)
|
||||
raw, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the part is not base64: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an
|
||||
// answer, not an error: that is how the module's health is read.
|
||||
func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) {
|
||||
if err := s.Settings.Missing(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := NewClient(s.Settings.Scanner).Status(ctx)
|
||||
if err != nil {
|
||||
return map[string]any{"answers": false, "why": err.Error()}, nil
|
||||
}
|
||||
return map[string]any{"answers": true, "state": st.State, "adf-state": st.AdfState}, nil
|
||||
}
|
||||
|
||||
// ScanList is the newest filed scans, newest first.
|
||||
func (s *Service) ScanList(limit int) (map[string]any, error) {
|
||||
if err := s.Settings.Missing(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scans, err := List(s.Settings.Folder, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"folder": s.Settings.Folder, "scans": scans}, nil
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
type said struct {
|
||||
body map[string]any
|
||||
id string
|
||||
// seen is whether the scan was kept in the bucket when it was said.
|
||||
seen bool
|
||||
}
|
||||
|
||||
// scanRig is the tools, the device worker and a bucket in memory over a settings file for the fake scanner and a
|
||||
// scan folder of the test's own, with the real defaults laid under what s sets.
|
||||
func scanRig(t *testing.T, f *fakeScanner, s Settings) (*printerRig, string) {
|
||||
t.Helper()
|
||||
if s.Scanner == "" {
|
||||
s.Scanner = f.base()
|
||||
}
|
||||
if s.Folder == "" {
|
||||
s.Folder = filepath.Join(t.TempDir(), "scans")
|
||||
}
|
||||
if s.Resolution == 0 {
|
||||
s.Resolution = 300
|
||||
}
|
||||
if s.ColorMode == "" {
|
||||
s.ColorMode = "color"
|
||||
}
|
||||
js, _ := json.Marshal(map[string]any{"scanner": s.Scanner, "scan-folder": s.Folder, "resolution": s.Resolution,
|
||||
"color-mode": s.ColorMode, "crop": s.Crop})
|
||||
return newRig(t, string(js), "pw"), s.Folder
|
||||
}
|
||||
|
||||
func (r *printerRig) scan(t *testing.T) (map[string]any, error) {
|
||||
t.Helper()
|
||||
got, err := r.call(t, "scanner.scan", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got.(map[string]any), nil
|
||||
}
|
||||
|
||||
// 1440/AC2: asked 300 dpi, the scanner gave 200: the event's resolution and millimetres, and the PDF's page,
|
||||
// are the image's own.
|
||||
func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false, Resolution: 300})
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["resolution"] != float64(200) {
|
||||
t.Errorf("resolution = %v, want the image's 200 (300 was asked)", body["resolution"])
|
||||
}
|
||||
if body["width-mm"] != 215.4 || body["height-mm"] != 279.8 {
|
||||
t.Errorf("size = %v x %v mm, want 215.4 x 279.8 (1696 x 2203 px at 200 dpi)", body["width-mm"], body["height-mm"])
|
||||
}
|
||||
pdf, _ := os.ReadFile(body["path"].(string))
|
||||
if !strings.Contains(string(pdf), "/MediaBox [0 0 610.56 793.08]") {
|
||||
t.Errorf("the page is not 200 dpi's size")
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC6 and 1453/AC9: one scan.added, said by the device process after the PDF is kept in the bucket under its
|
||||
// sha256, with every key, the object's key and never the image; the tool then files the PDF.
|
||||
func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: true})
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := *r.said
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("%d events, want 1", len(out))
|
||||
}
|
||||
ev := out[0]
|
||||
if !ev.seen {
|
||||
t.Error("the event was said before the PDF was kept in the bucket")
|
||||
}
|
||||
for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} {
|
||||
if _, ok := ev.body[k]; !ok {
|
||||
t.Errorf("the event lacks %s", k)
|
||||
}
|
||||
}
|
||||
if len(ev.body) != 10 {
|
||||
t.Errorf("the event has %d keys, want the ten named (no path: filing is the tool's)", len(ev.body))
|
||||
}
|
||||
pdf, err := os.ReadFile(body["path"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(pdf)
|
||||
want := hex.EncodeToString(sum[:])
|
||||
if ev.body["sha256"] != want || ev.id != want || ev.body["object"] != want+".pdf" {
|
||||
t.Errorf("sha256 %v / message id %v / object %v, want the file's %s", ev.body["sha256"], ev.id, ev.body["object"], want)
|
||||
}
|
||||
if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) {
|
||||
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
|
||||
}
|
||||
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
|
||||
t.Errorf("event = %v", ev.body)
|
||||
}
|
||||
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
|
||||
t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want)
|
||||
}
|
||||
if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 11 {
|
||||
t.Errorf("the tool's answer has %d keys, want the event's ten and the path", len(body))
|
||||
}
|
||||
if _, has := body["key"]; has {
|
||||
t.Errorf("the device process's own answer keys leak into the tool's: %v", body)
|
||||
}
|
||||
// The cropped scan is the receipt, about 80-84 mm wide (1440/AC3).
|
||||
if w := ev.body["width-mm"].(float64); w < 80 || w > 84 {
|
||||
t.Errorf("width = %v mm", w)
|
||||
}
|
||||
files, _ := os.ReadDir(folder)
|
||||
if len(files) != 1 || filepath.Join(folder, files[0].Name()) != body["path"] {
|
||||
t.Errorf("folder holds %v, want the one scan", files)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC9: the same scan twice is one object in the bucket: a key is kept once.
|
||||
func TestTheSameScanTwiceIsOneObject(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: true})
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
_ = f.documents
|
||||
f.mu.Unlock()
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.objects.puts != 1 || len(r.objects.keys()) != 1 {
|
||||
t.Errorf("%d puts, objects %v: want the one", r.objects.puts, r.objects.keys())
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC6: a scanner that refuses keeps nothing, writes nothing, says nothing, and the error says why.
|
||||
func TestAScannerRefusalKeepsWritesAndSaysNothing(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.refuse = 503
|
||||
r, folder := scanRig(t, f, Settings{})
|
||||
_, err := r.scan(t)
|
||||
if err == nil || !strings.Contains(err.Error(), "503") {
|
||||
t.Fatalf("error = %v, want the refusal said", err)
|
||||
}
|
||||
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
|
||||
t.Errorf("%d events, objects %v after a refusal", len(*r.said), r.objects.keys())
|
||||
}
|
||||
if _, err := os.Stat(folder); !os.IsNotExist(err) {
|
||||
t.Errorf("the folder exists after a refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC6: a job that gives no page (the second NextDocument's 404) is the same.
|
||||
func TestAJobThatGivesNoPageKeepsWritesAndSaysNothing(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
f.noPage = true
|
||||
r, folder := scanRig(t, f, Settings{})
|
||||
if _, err := r.scan(t); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
|
||||
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
|
||||
}
|
||||
if _, err := os.Stat(folder); !os.IsNotExist(err) {
|
||||
t.Errorf("the folder exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC6: an image that is no JPEG (a scanner that sent something else) keeps and files nothing.
|
||||
func TestAnImageThatIsNotAJPEGKeepsAndFilesNothing(t *testing.T) {
|
||||
f := newFakeScanner(t, []byte("not an image"))
|
||||
r, folder := scanRig(t, f, Settings{})
|
||||
if _, err := r.scan(t); err == nil {
|
||||
t.Fatal("a non-image was filed")
|
||||
}
|
||||
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
|
||||
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
|
||||
}
|
||||
if _, err := os.Stat(folder); !os.IsNotExist(err) {
|
||||
t.Errorf("the folder exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC11: the first scan into a folder that is not there makes it.
|
||||
func TestTheFirstScanMakesTheFolder(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{})
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(folder)
|
||||
if err != nil || info.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("folder %v %v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func settingsFile(t *testing.T, json string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(json), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func tool(t *testing.T, tl []stdio.Tool, name string) stdio.Tool {
|
||||
t.Helper()
|
||||
for _, x := range tl {
|
||||
if x.Name == name {
|
||||
return x
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return stdio.Tool{}
|
||||
}
|
||||
|
||||
// 1440/AC7: scanner or folder unset: every tool says which, and nothing is written or asked.
|
||||
func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
for _, c := range []struct{ json, want string }{
|
||||
{`{"scanner":"` + "%s" + `","scan-folder":""}`, "scan-folder"},
|
||||
{`{"scanner":"","scan-folder":"FOLDER"}`, "scanner"},
|
||||
{`{}`, "scanner and scan-folder"},
|
||||
} {
|
||||
folder := filepath.Join(t.TempDir(), "scans")
|
||||
js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder)
|
||||
r := newRig(t, js, "pw")
|
||||
out := *r.said
|
||||
for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} {
|
||||
_, err := tool(t, r.tl, name).Run(map[string]any{})
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want)
|
||||
}
|
||||
}
|
||||
if f.posts != 0 || f.documents != 0 || len(out) != 0 || r.bus.asks() != 0 || len(r.objects.keys()) != 0 {
|
||||
t.Errorf("the scanner was asked, an ask queued or an event said: %d posts, %d documents, %d events, %d asks", f.posts, f.documents, len(out), r.bus.asks())
|
||||
}
|
||||
if _, err := os.Stat(folder); !os.IsNotExist(err) {
|
||||
t.Errorf("the folder was made: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC7: the settings file is not there at all.
|
||||
func TestAMissingSettingsFileIsSaid(t *testing.T) {
|
||||
tl := tools(filepath.Join(t.TempDir(), "none.json"), time.Now)
|
||||
if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC8: scan_status from the captured idle status; a scanner that does not answer is answers false.
|
||||
func TestScanStatusSaysStateAndAdfState(t *testing.T) {
|
||||
f := newFakeScanner(t, nil)
|
||||
svc := &Service{Settings: Settings{Scanner: f.base(), Folder: "/x"}}
|
||||
got, err := svc.ScanStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["answers"] != true || got["state"] != "Idle" || got["adf-state"] != "ScannerAdfEmpty" {
|
||||
t.Fatalf("status = %v", got)
|
||||
}
|
||||
f.Close()
|
||||
got, err = svc.ScanStatus(context.Background())
|
||||
if err != nil || got["answers"] != false || got["why"] == "" {
|
||||
t.Fatalf("a scanner that is gone: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC8: scan_list through the tool, newest first.
|
||||
func TestScanListToolListsTheNewestFirst(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||
for i, n := range []string{"scan-1.pdf", "scan-2.pdf"} {
|
||||
p := filepath.Join(folder, n)
|
||||
os.WriteFile(p, []byte("x"), 0o644)
|
||||
os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour))
|
||||
}
|
||||
tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), time.Now)
|
||||
got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scans := got.(map[string]any)["scans"].([]Entry)
|
||||
if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" {
|
||||
t.Fatalf("scans = %v", scans)
|
||||
}
|
||||
if _, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(0)}); err == nil {
|
||||
t.Error("limit 0 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC1: the whole path through the tool, settings read from the file, with the real defaults.
|
||||
func TestScanNowToolScansWithTheFileSettings(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
folder := filepath.Join(t.TempDir(), "scans")
|
||||
r := newRig(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`, "pw")
|
||||
got, err := r.call(t, "scanner.scan", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*r.said) != 1 || got.(map[string]any)["cropped"] != true {
|
||||
t.Fatalf("%d events, body %v", len(*r.said), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSettingsLaysTheDefaultsUnder(t *testing.T) {
|
||||
s, err := LoadSettings(settingsFile(t, `{"scanner":" http://192.0.2.10/eSCL ","scan-folder":"/srv/scans"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Scanner != "http://192.0.2.10/eSCL" || s.Folder != "/srv/scans" || s.Resolution != 300 || s.ColorMode != "color" || !s.Crop {
|
||||
t.Fatalf("settings = %+v", s)
|
||||
}
|
||||
s, _ = LoadSettings(settingsFile(t, `{"resolution":600,"color-mode":"gray","crop":false}`))
|
||||
if s.Resolution != 600 || s.ColorMode != "gray" || s.Crop {
|
||||
t.Fatalf("settings = %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package main is the brother module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a
|
||||
// network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus.
|
||||
// A scan happens when a tool is called; the module runs no process of its own.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Settings are the module's seven config values, as the mesh writes them into settings.json (ADR 0348).
|
||||
type Settings struct {
|
||||
// Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
|
||||
Scanner string
|
||||
// Folder is the absolute path the PDFs are filed in. It is not a manifest resource: a directory's path
|
||||
// cannot take a config value (issue 1441), so the code creates it.
|
||||
Folder string
|
||||
// Resolution is the dpi asked of the scanner: 100, 200, 300 or 600.
|
||||
Resolution int
|
||||
// ColorMode is "color" or "gray".
|
||||
ColorMode string
|
||||
// Printer is the device's host (with a port if it is not 443), such as 192.0.2.10: the host of its admin
|
||||
// page and its IPP service (ADR 0467 clause 3).
|
||||
Printer string
|
||||
// PrinterCertificate is the SHA-256 fingerprint of the one certificate the device's admin page may serve.
|
||||
PrinterCertificate string
|
||||
// Crop says whether the document is cut out of the platen's white.
|
||||
Crop bool
|
||||
}
|
||||
|
||||
// LoadSettings reads settings.json (the file named by BROTHER_SETTINGS) and lays the defaults under it. A missing
|
||||
// or unreadable file is an error that names it; an unset scanner or folder is Missing's to say.
|
||||
func LoadSettings(path string) (Settings, error) {
|
||||
s := Settings{Resolution: 300, ColorMode: "color", Crop: true}
|
||||
if path == "" {
|
||||
return s, fmt.Errorf("BROTHER_SETTINGS names no settings file: the module's config does not reach the code")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("settings cannot be read: %w", err)
|
||||
}
|
||||
var in map[string]any
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return s, fmt.Errorf("settings are not JSON (%s): %w", path, err)
|
||||
}
|
||||
if v, ok := in["scanner"].(string); ok {
|
||||
s.Scanner = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["scan-folder"].(string); ok {
|
||||
s.Folder = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["resolution"].(float64); ok && v > 0 {
|
||||
s.Resolution = int(v)
|
||||
}
|
||||
if v, ok := in["color-mode"].(string); ok && v != "" {
|
||||
s.ColorMode = v
|
||||
}
|
||||
if v, ok := in["printer"].(string); ok {
|
||||
s.Printer = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["printer-certificate"].(string); ok {
|
||||
s.PrinterCertificate = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["crop"].(bool); ok {
|
||||
s.Crop = v
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Missing is nil when both required values are set, and otherwise says which is not: no tool writes or
|
||||
// asks anything before that (1440/AC7).
|
||||
func (s Settings) Missing() error {
|
||||
var unset []string
|
||||
if s.Scanner == "" {
|
||||
unset = append(unset, "scanner")
|
||||
}
|
||||
if s.Folder == "" {
|
||||
unset = append(unset, "scan-folder")
|
||||
}
|
||||
if len(unset) == 0 {
|
||||
return nil
|
||||
}
|
||||
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 "))
|
||||
}
|
||||
|
||||
// unsetError says which config values are not set.
|
||||
func unsetError(names ...string) error {
|
||||
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
|
||||
strings.Join(names, " and "))
|
||||
}
|
||||
|
||||
// MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not:
|
||||
// nothing is contacted before that (1453/AC4).
|
||||
func (s Settings) MissingPrinter() error {
|
||||
var unset []string
|
||||
if s.Printer == "" {
|
||||
unset = append(unset, "printer")
|
||||
}
|
||||
if s.PrinterCertificate == "" {
|
||||
unset = append(unset, "printer-certificate")
|
||||
}
|
||||
if len(unset) == 0 {
|
||||
return nil
|
||||
}
|
||||
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 "))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
|
||||
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/general/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2"><a href="/home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto Refresh Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Please Login</h2></div><div id="pageContents"><div class="contentsGroup"><p class="noteMessage">To Access this page you are required to Login, please Login.</p></div></div></div></div></div></div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 2974
|
||||
Content-Type: text/html
|
||||
Content-Language: en-gb
|
||||
Connection: close
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Pragma: no-cache
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
HTTP/1.1 302 Moved Temporarily
|
||||
Cache-Control: no-store
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 0
|
||||
Connection: close
|
||||
Pragma: no-cache
|
||||
Location: /etc/loginerror.html?url=%2Fgeneral%2Fstatus%2Ehtml
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
|
||||
<script type="text/javascript" src="../common/js/cookie.js"></script>
|
||||
<script type="text/javascript" src="../common/js/language.js"></script>
|
||||
<script type="text/javascript" src="../common/js/lcddisplay.js"></script>
|
||||
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/home/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2 selected"><a href="../home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto Refresh Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Status</h2></div><div id="pageContents"><form method="post" action="/home/status.html"><div><input type="hidden" id="pageid" name="pageid" value="1" /></div><div class="contentsGroup"><dl class="items"><dt>Device Status</dt><dd><div id="moni_data"><span class="moni moniError"></span></div></dd><dt class="subhead"><span>Automatic Refresh</span></dt><dd><div><label class="radioSimple"><input type="radio" name="Refresh" value="0" onclick="refreshLCD(0);" checked="checked" />
|
||||
Off</label><label class="radioSimple"><input type="radio" name="Refresh" value="1" onclick="refreshLCD(60000);" />
|
||||
On</label></div></dd><dt>Toner Level</dt><dd><div id="ink_level"><table id="inkLevel" summary="ink level"><tr><th></th><th><img src="../common/images/low.gif" alt="Low" /></th><th><img src="../common/images/replace.gif" alt="Replace" /></th><th></th></tr><tr><td><img src="../common/images/black.gif" alt="Black" class="tonerremain" height="22" /></td><td><img src="../common/images/cyan.gif" alt="Cyan" class="tonerremain" height="5" /></td><td><img src="../common/images/magenta.gif" alt="Magenta" class="tonerremain" height="0" /></td><td><img src="../common/images/yellow.gif" alt="Yellow" class="tonerremain" height="56" /></td></tr><tr><th>BK</th><th>C</th><th>M</th><th>Y</th></tr></table></div></dd></dl></div><div class="contentsGroup"><dl class="items"><dt>Web Language</dt><dd><select id="Language" name="Language" onchange="changeLanguage('Language', 'WebLang');"><option value="0" selected="selected">Auto</option><option value="2">English</option><option value="3">French</option><option value="4">German</option><option value="5">Dutch</option><option value="6">Spanish</option><option value="7">Italian</option><option value="10">Russian</option><option value="11">Portuguese</option><option value="12">Polish</option></select></dd></dl></div><div class="contentsGroup"><dl class="items"><dt>Device Location</dt><dd><ul class="itemList"><li class="contact">Contact<span class="spacer">:</span></li><li class="location">Location<span class="spacer">:</span></li></ul></dd></dl></div></form></div></div></div></div><script type="text/javascript">
|
||||
<!--
|
||||
judge_refresh(60000);
|
||||
//-->
|
||||
</script>
|
||||
</div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
|
||||
@@ -0,0 +1,11 @@
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 5180
|
||||
Content-Type: text/html
|
||||
Content-Language: en-gb
|
||||
Connection: close
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Pragma: no-cache
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
HTTP/1.1 301 Moved Permanently
|
||||
Cache-Control: no-store
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 0
|
||||
Connection: close
|
||||
Pragma: no-cache
|
||||
Location: /home/status.html
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 2974
|
||||
Content-Type: text/html
|
||||
Content-Language: en-gb
|
||||
Connection: close
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Pragma: no-cache
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
|
||||
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/general/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2"><a href="/home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto Refresh Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Please Login</h2></div><div id="pageContents"><div class="contentsGroup"><p class="noteMessage">To Access this page you are required to Login, please Login.</p></div></div></div></div></div></div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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 two form actions, the first form's hidden fields CSRFToken and B1892, the second form's 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="/home/status.html"><input type="hidden" name="CSRFToken" value="TOKEN"/><input type="hidden" name="B1892" value=""/></form>
|
||||
<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>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic settings</title></head><body><a href="/home/logout.html">Logout</a><a href="/general/status.html">Status</a><form method="post" action="/general/other.html"><table><tr><td><label for="lock">Scan lock</label></td><td><input type="checkbox" id="lock" name="lock" value="on"/></td></tr><tr><td><label for="name">Name</label></td><td><input type="text" id="name" name="node" value="BRN-SYNTH"/></td></tr><tr><td><label for="mode">Mode</label></td><td><select id="mode" name="mode"><option value="a">Auto</option><option value="m" selected="selected">Manual</option></select></td></tr><tr><td><label>Secret<input type="password" name="pw" value="shouldnotshow"/></label></td></tr><tr><td><textarea name="note">hello & bye</textarea></td></tr></table><input type="hidden" name="token" value="t1"/><input type="submit" value="Save"/></form></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic status</title></head><body><div id="LogInOutBox"><a href="/home/logout.html">Logout</a></div><div id="pageTitle"><h2>Status</h2></div><div><a href="/general/other.html">Other page</a><a href="/net/net/airprint.html">AirPrint</a></div></body></html>
|
||||
@@ -0,0 +1,261 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScannerCapabilities
|
||||
xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03"
|
||||
xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm"
|
||||
xmlns:dest="http://schemas.hp.com/imaging/destination/2011/06/06"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd">
|
||||
<pwg:Version>2.93</pwg:Version>
|
||||
<pwg:MakeAndModel>Brother MFC-L8390CDW series</pwg:MakeAndModel>
|
||||
<pwg:SerialNumber>E82414G4N581626</pwg:SerialNumber>
|
||||
<scan:Manufacturer>Brother</scan:Manufacturer>
|
||||
<scan:UUID>e3248000-80ce-11db-8000-94ddf83ec78a</scan:UUID>
|
||||
<scan:AdminURI>http://BRW58CDC9DFC80B.local./net/net/airprint.html</scan:AdminURI>
|
||||
<scan:IconURI>http://BRW58CDC9DFC80B.local./icons/device-icons-128.png</scan:IconURI>
|
||||
<scan:Certifications>
|
||||
<scan:Certification>
|
||||
<scan:Name>mopria-certified-scan</scan:Name>
|
||||
<scan:Version>1.4</scan:Version>
|
||||
</scan:Certification>
|
||||
</scan:Certifications>
|
||||
<scan:Platen>
|
||||
<scan:PlatenInputCaps>
|
||||
<scan:MinWidth>64</scan:MinWidth>
|
||||
<scan:MaxWidth>2550</scan:MaxWidth>
|
||||
<scan:MinHeight>64</scan:MinHeight>
|
||||
<scan:MaxHeight>3507</scan:MaxHeight>
|
||||
<scan:MaxXOffset>2486</scan:MaxXOffset>
|
||||
<scan:MaxYOffset>3443</scan:MaxYOffset>
|
||||
<scan:MaxScanRegions>1</scan:MaxScanRegions>
|
||||
<scan:SettingProfiles>
|
||||
<scan:SettingProfile>
|
||||
<scan:ColorModes>
|
||||
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
|
||||
<scan:ColorMode>Grayscale8</scan:ColorMode>
|
||||
<scan:ColorMode>RGB24</scan:ColorMode>
|
||||
</scan:ColorModes>
|
||||
<scan:DocumentFormats>
|
||||
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
|
||||
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
||||
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
|
||||
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
|
||||
</scan:DocumentFormats>
|
||||
<scan:SupportedResolutions>
|
||||
<scan:DiscreteResolutions>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>100</scan:XResolution>
|
||||
<scan:YResolution>100</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>200</scan:XResolution>
|
||||
<scan:YResolution>200</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>300</scan:XResolution>
|
||||
<scan:YResolution>300</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>600</scan:XResolution>
|
||||
<scan:YResolution>600</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
</scan:DiscreteResolutions>
|
||||
</scan:SupportedResolutions>
|
||||
<scan:ColorSpaces>
|
||||
<scan:ColorSpace>CMYK</scan:ColorSpace>
|
||||
<scan:ColorSpace>YCC</scan:ColorSpace>
|
||||
<scan:ColorSpace>sRGB</scan:ColorSpace>
|
||||
</scan:ColorSpaces>
|
||||
<scan:CcdChannels>
|
||||
<scan:CcdChannel>Red</scan:CcdChannel>
|
||||
<scan:CcdChannel>Green</scan:CcdChannel>
|
||||
<scan:CcdChannel>Blue</scan:CcdChannel>
|
||||
<scan:CcdChannel>NTSC</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
|
||||
</scan:CcdChannels>
|
||||
<scan:BinaryRenderings>
|
||||
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
|
||||
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
|
||||
</scan:BinaryRenderings>
|
||||
</scan:SettingProfile>
|
||||
</scan:SettingProfiles>
|
||||
<scan:SupportedIntents>
|
||||
<scan:Intent>Document</scan:Intent>
|
||||
<scan:Intent>TextAndGraphic</scan:Intent>
|
||||
<scan:Intent>Photo</scan:Intent>
|
||||
<scan:Intent>Preview</scan:Intent>
|
||||
</scan:SupportedIntents>
|
||||
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
|
||||
<scan:MaxOpticalYResolution>2400</scan:MaxOpticalYResolution>
|
||||
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
|
||||
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
|
||||
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
|
||||
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
|
||||
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
|
||||
<scan:MaxPhysicalHeight>3507</scan:MaxPhysicalHeight>
|
||||
</scan:PlatenInputCaps>
|
||||
</scan:Platen>
|
||||
<scan:Adf>
|
||||
<scan:AdfSimplexInputCaps>
|
||||
<scan:MinWidth>64</scan:MinWidth>
|
||||
<scan:MaxWidth>2550</scan:MaxWidth>
|
||||
<scan:MinHeight>1740</scan:MinHeight>
|
||||
<scan:MaxHeight>4200</scan:MaxHeight>
|
||||
<scan:MaxXOffset>2486</scan:MaxXOffset>
|
||||
<scan:MaxYOffset>2460</scan:MaxYOffset>
|
||||
<scan:MaxScanRegions>1</scan:MaxScanRegions>
|
||||
<scan:SettingProfiles>
|
||||
<scan:SettingProfile>
|
||||
<scan:ColorModes>
|
||||
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
|
||||
<scan:ColorMode>Grayscale8</scan:ColorMode>
|
||||
<scan:ColorMode>RGB24</scan:ColorMode>
|
||||
</scan:ColorModes>
|
||||
<scan:DocumentFormats>
|
||||
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
|
||||
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
||||
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
|
||||
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
|
||||
</scan:DocumentFormats>
|
||||
<scan:SupportedResolutions>
|
||||
<scan:DiscreteResolutions>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>100</scan:XResolution>
|
||||
<scan:YResolution>100</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>200</scan:XResolution>
|
||||
<scan:YResolution>200</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>300</scan:XResolution>
|
||||
<scan:YResolution>300</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>600</scan:XResolution>
|
||||
<scan:YResolution>600</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
</scan:DiscreteResolutions>
|
||||
</scan:SupportedResolutions>
|
||||
<scan:ColorSpaces>
|
||||
<scan:ColorSpace>CMYK</scan:ColorSpace>
|
||||
<scan:ColorSpace>YCC</scan:ColorSpace>
|
||||
<scan:ColorSpace>sRGB</scan:ColorSpace>
|
||||
</scan:ColorSpaces>
|
||||
<scan:CcdChannels>
|
||||
<scan:CcdChannel>Red</scan:CcdChannel>
|
||||
<scan:CcdChannel>Green</scan:CcdChannel>
|
||||
<scan:CcdChannel>Blue</scan:CcdChannel>
|
||||
<scan:CcdChannel>NTSC</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
|
||||
</scan:CcdChannels>
|
||||
<scan:BinaryRenderings>
|
||||
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
|
||||
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
|
||||
</scan:BinaryRenderings>
|
||||
</scan:SettingProfile>
|
||||
</scan:SettingProfiles>
|
||||
<scan:SupportedIntents>
|
||||
<scan:Intent>Document</scan:Intent>
|
||||
<scan:Intent>TextAndGraphic</scan:Intent>
|
||||
<scan:Intent>Photo</scan:Intent>
|
||||
<scan:Intent>Preview</scan:Intent>
|
||||
</scan:SupportedIntents>
|
||||
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
|
||||
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
|
||||
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
|
||||
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
|
||||
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
|
||||
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
|
||||
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
|
||||
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
|
||||
</scan:AdfSimplexInputCaps>
|
||||
<scan:AdfDuplexInputCaps>
|
||||
<scan:MinWidth>64</scan:MinWidth>
|
||||
<scan:MaxWidth>2550</scan:MaxWidth>
|
||||
<scan:MinHeight>1740</scan:MinHeight>
|
||||
<scan:MaxHeight>4200</scan:MaxHeight>
|
||||
<scan:MaxXOffset>2486</scan:MaxXOffset>
|
||||
<scan:MaxYOffset>2460</scan:MaxYOffset>
|
||||
<scan:MaxScanRegions>1</scan:MaxScanRegions>
|
||||
<scan:SettingProfiles>
|
||||
<scan:SettingProfile>
|
||||
<scan:ColorModes>
|
||||
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
|
||||
<scan:ColorMode>Grayscale8</scan:ColorMode>
|
||||
<scan:ColorMode>RGB24</scan:ColorMode>
|
||||
</scan:ColorModes>
|
||||
<scan:DocumentFormats>
|
||||
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
|
||||
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
||||
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
|
||||
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
|
||||
</scan:DocumentFormats>
|
||||
<scan:SupportedResolutions>
|
||||
<scan:DiscreteResolutions>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>100</scan:XResolution>
|
||||
<scan:YResolution>100</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>200</scan:XResolution>
|
||||
<scan:YResolution>200</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>300</scan:XResolution>
|
||||
<scan:YResolution>300</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
<scan:DiscreteResolution>
|
||||
<scan:XResolution>600</scan:XResolution>
|
||||
<scan:YResolution>600</scan:YResolution>
|
||||
</scan:DiscreteResolution>
|
||||
</scan:DiscreteResolutions>
|
||||
</scan:SupportedResolutions>
|
||||
<scan:ColorSpaces>
|
||||
<scan:ColorSpace>CMYK</scan:ColorSpace>
|
||||
<scan:ColorSpace>YCC</scan:ColorSpace>
|
||||
<scan:ColorSpace>sRGB</scan:ColorSpace>
|
||||
</scan:ColorSpaces>
|
||||
<scan:CcdChannels>
|
||||
<scan:CcdChannel>Red</scan:CcdChannel>
|
||||
<scan:CcdChannel>Green</scan:CcdChannel>
|
||||
<scan:CcdChannel>Blue</scan:CcdChannel>
|
||||
<scan:CcdChannel>NTSC</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
|
||||
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
|
||||
</scan:CcdChannels>
|
||||
<scan:BinaryRenderings>
|
||||
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
|
||||
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
|
||||
</scan:BinaryRenderings>
|
||||
</scan:SettingProfile>
|
||||
</scan:SettingProfiles>
|
||||
<scan:SupportedIntents>
|
||||
<scan:Intent>Document</scan:Intent>
|
||||
<scan:Intent>TextAndGraphic</scan:Intent>
|
||||
<scan:Intent>Photo</scan:Intent>
|
||||
<scan:Intent>Preview</scan:Intent>
|
||||
</scan:SupportedIntents>
|
||||
<scan:MaxOpticalXResolution>600</scan:MaxOpticalXResolution>
|
||||
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
|
||||
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
|
||||
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
|
||||
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
|
||||
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
|
||||
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
|
||||
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
|
||||
</scan:AdfDuplexInputCaps>
|
||||
<scan:FeederCapacity>50</scan:FeederCapacity>
|
||||
<scan:AdfOptions>
|
||||
<scan:AdfOption>DetectPaperLoaded</scan:AdfOption>
|
||||
<scan:AdfOption>Duplex</scan:AdfOption>
|
||||
</scan:AdfOptions>
|
||||
</scan:Adf>
|
||||
<scan:CompressionFactorSupport>
|
||||
<scan:Min>1</scan:Min>
|
||||
<scan:Max>3</scan:Max>
|
||||
<scan:Normal>2</scan:Normal>
|
||||
<scan:Step>1</scan:Step>
|
||||
</scan:CompressionFactorSupport>
|
||||
</scan:ScannerCapabilities>
|
||||
@@ -0,0 +1,6 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Location: /eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a/doc-1
|
||||
Accept-Encoding: identity
|
||||
Content-Type: image/jpeg
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 201 Created
|
||||
Cache-Control: no-store
|
||||
Accept-Encoding: identity
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 0
|
||||
Content-Type: text/xml
|
||||
Connection: close
|
||||
Pragma: no-cache
|
||||
Location: http://192.168.1.171/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
|
||||
<pwg:Version>2.63</pwg:Version>
|
||||
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>2550</pwg:Width><pwg:Height>3507</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
|
||||
<pwg:InputSource>Platen</pwg:InputSource>
|
||||
<scan:ColorMode>RGB24</scan:ColorMode>
|
||||
<scan:XResolution>300</scan:XResolution><scan:YResolution>300</scan:YResolution>
|
||||
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
||||
</scan:ScanSettings>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9291071</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9292192</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBVzCB/qADAgECAgQoWaUiMAoGCCqGSM49BAMCMCAxHjAcBgNVBAMTFUJSVzU4
|
||||
Q0RDOURGQzgwQi5sb2NhbDAgFw0wMDAxMDEwMDAwMDBaGA8yMTEwMTIzMTIzNTk1
|
||||
OVowIDEeMBwGA1UEAxMVQlJXNThDREM5REZDODBCLmxvY2FsMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAENrR0q9JKcMSypD7yVGMzXpaarlLA3nc4ju1gh9ymeqjy
|
||||
xyfdiAy08GgvGKZMv+EaqH1xoAaaglmF8Dhgn0GrS6MkMCIwIAYDVR0RBBkwF4IV
|
||||
QlJXNThDREM5REZDODBCLmxvY2FsMAoGCCqGSM49BAMCA0gAMEUCIQCTpBb5+82s
|
||||
f/OXfEA77SJREW+6UHFgaDruuJ06wdTouwIgUUr5FkoGzsunwrYAZmqcBw96UKK5
|
||||
o5tz3ZLrtSke6VM=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC5DCCAcygAwIBAgIEIvomBDANBgkqhkiG9w0BAQsFADAgMR4wHAYDVQQDExVC
|
||||
Ulc1OENEQzlERkM4MEIubG9jYWwwIBcNMDAwMTAxMDAwMDAwWhgPMjExMDEyMzEy
|
||||
MzU5NTlaMCAxHjAcBgNVBAMTFUJSVzU4Q0RDOURGQzgwQi5sb2NhbDCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMGbBrytUZsYjcH/d75H1ax+EMKElDWX
|
||||
p+bnafu5vB0WooGCG7gYbCGuYJRnI+PtFGEVwPlOQojiGmvuDVO2THs+kefcAi/c
|
||||
7inSrD550kBsfWYVVhyZ3e0tK+UsEgTC9QQ0fdi3AFWnS68L93ooPkCgVBCl1B/c
|
||||
5VsoGOtdFGgDLPJlmf22KxfSjI2+0cB4/f+VJX+1AoP71s1A4NN8okfuEL+mC1h1
|
||||
tbzaT+MLTtgxAFMu//tF3287yVLyG+vSBAbX7pujpBsrzBzv5mn2dZrlPwDjD17w
|
||||
oqfjfuu1ERpxn3fpBtexqXV3ybw2ftGGOVtn9kV2Po/vTTfGMgrz33UCAwEAAaMk
|
||||
MCIwIAYDVR0RBBkwF4IVQlJXNThDREM5REZDODBCLmxvY2FsMA0GCSqGSIb3DQEB
|
||||
CwUAA4IBAQBfP6IfdFsaWyc8FHooiRW/oS9OlZxDUrmUrdNP9doRq7PROM2bCnIZ
|
||||
D6q5B34bLFxHmitMLpy3MKV475yrS83ges5k5rlUPlRFIJaSCpNY6xRwUZdGgurX
|
||||
h8KdHERXmtCuj1egkTJ9SRAchjlgdQevVJRgeFUCdVZ63NVIBy37zcIVBHOB5R3L
|
||||
V9lMijXwV2ow9DnXARdXYeSn98XoOMVNyL8lHESyHPN2cS8fijunudGtoieSA0TK
|
||||
NpAr18jfGBYvM35AYwvSFGqo/yECCfKVHup2HykhOqhWnrvv8OyHmsvdYn2s9bx1
|
||||
lT903TyZyla6PjeA/8dGj4eJHkVIKv87
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"scanner": "",
|
||||
"scan-folder": "",
|
||||
"printer": "",
|
||||
"printer-certificate": "",
|
||||
"resolution": 300,
|
||||
"color-mode": "color",
|
||||
"crop": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"scheme": "${bound:s3-bucket:scheme}",
|
||||
"at": "${bound:s3-bucket:at}",
|
||||
"port": "${bound:s3-bucket:port}",
|
||||
"bucket": "${bound:s3-bucket:bucket}",
|
||||
"region": "${bound:s3-bucket:region}",
|
||||
"key": "${bound:s3-bucket:as}",
|
||||
"secret": "${secret:s3-bucket}"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module brother
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"s3-bucket"
|
||||
],
|
||||
"binds": {
|
||||
"s3-bucket": "${dir:state}/store-binding.json"
|
||||
},
|
||||
"secrets": {
|
||||
"s3-bucket": "${dir:state}/store.secret"
|
||||
},
|
||||
"config": {
|
||||
"scanner": {
|
||||
"type": "string",
|
||||
"description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.",
|
||||
"pattern": "^https?://.+",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"scan-folder": {
|
||||
"type": "string",
|
||||
"description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.",
|
||||
"pattern": "^/.+$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer": {
|
||||
"type": "string",
|
||||
"description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.",
|
||||
"pattern": "^[A-Za-z0-9.:-]+$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer-certificate": {
|
||||
"type": "string",
|
||||
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons; several, separated by commas, when the printer serves more than one (it serves an ECDSA and an RSA certificate for one name). The module trusts those certificates alone and refuses any other; the operator's, with no default.",
|
||||
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}(, *([0-9A-F]{2}:){31}[0-9A-F]{2})*$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"resolution": {
|
||||
"type": "number",
|
||||
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
|
||||
"enum": [
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
600
|
||||
],
|
||||
"trusted": false,
|
||||
"default": 300
|
||||
},
|
||||
"color-mode": {
|
||||
"type": "string",
|
||||
"description": "Whether the scan is in colour or in grays: color or gray.",
|
||||
"enum": [
|
||||
"color",
|
||||
"gray"
|
||||
],
|
||||
"trusted": false,
|
||||
"default": "color"
|
||||
},
|
||||
"crop": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
|
||||
"trusted": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin-password": {
|
||||
"path": "${dir:state}/admin-password.secret",
|
||||
"issued-by": "outside",
|
||||
"prompt": "the admin password of the Brother printer",
|
||||
"owner": "brother-device"
|
||||
},
|
||||
"broker-device": {
|
||||
"path": "${dir:state}/broker-device.secret",
|
||||
"owner": "brother-device"
|
||||
}
|
||||
},
|
||||
"streams": [
|
||||
{
|
||||
"name": "device-asks",
|
||||
"events": [
|
||||
"device.asked"
|
||||
],
|
||||
"max-age-days": 1,
|
||||
"max-mib": 8,
|
||||
"retention": "work-queue",
|
||||
"max-deliveries": 3,
|
||||
"duplicates": 24
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "device-answers",
|
||||
"ttl-seconds": 600
|
||||
}
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "scanner",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"scan",
|
||||
"status",
|
||||
"list",
|
||||
"document"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "printer",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"status",
|
||||
"settings",
|
||||
"print",
|
||||
"upload"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "scanner",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"scan",
|
||||
"status",
|
||||
"list",
|
||||
"document"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "printer",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"status",
|
||||
"settings",
|
||||
"print",
|
||||
"upload"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
],
|
||||
"tools": [
|
||||
"scanner.scan",
|
||||
"scanner.status",
|
||||
"scanner.list",
|
||||
"scanner.document",
|
||||
"printer.status",
|
||||
"printer.settings",
|
||||
"printer.print",
|
||||
"printer.upload"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "brother-device",
|
||||
"shell": "/usr/bin/nologin"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0711",
|
||||
"owner": "${machine:account}",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"takes-config": [
|
||||
"scanner",
|
||||
"scan-folder",
|
||||
"printer",
|
||||
"printer-certificate",
|
||||
"resolution",
|
||||
"color-mode",
|
||||
"crop"
|
||||
],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/store.json",
|
||||
"owner": "brother-device",
|
||||
"mode": "0600",
|
||||
"template": "files/store.json"
|
||||
},
|
||||
{
|
||||
"id": "device",
|
||||
"type": "process",
|
||||
"name": "brother-device",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./brother",
|
||||
"device"
|
||||
],
|
||||
"user": "brother-device",
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret",
|
||||
"BROTHER_BROKER": "${dir:state}/broker-device.secret",
|
||||
"BROTHER_STORE": "${dir:state}/store.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"settings",
|
||||
"store"
|
||||
],
|
||||
"bus": {
|
||||
"queues": [
|
||||
"device-asks"
|
||||
],
|
||||
"state": [
|
||||
"device-answers"
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/brother",
|
||||
"binary": "brother",
|
||||
"loads": [
|
||||
"brother"
|
||||
],
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
// Issue 1481 (novox/hq ADR 0466 §3): the build modules' keys on one forge user that reads and pushes nothing. The forge
|
||||
// is faked with the teams, members and keys it keeps, so a team's shape and a key's right can be read back.
|
||||
|
||||
type forgeKey struct {
|
||||
ID int64
|
||||
Title string
|
||||
Key string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
type teamForge struct {
|
||||
mu sync.Mutex
|
||||
users map[string]bool
|
||||
teams map[string]map[string]any // by name: the shape last sent
|
||||
ids map[string]int64
|
||||
members map[string][]string // by team name
|
||||
keys map[string][]forgeKey
|
||||
next int64
|
||||
}
|
||||
|
||||
func newTeamForge() *teamForge {
|
||||
return &teamForge{users: map[string]bool{}, teams: map[string]map[string]any{}, ids: map[string]int64{},
|
||||
members: map[string][]string{}, keys: map[string][]forgeKey{}, next: 10}
|
||||
}
|
||||
|
||||
var (
|
||||
teamRe = regexp.MustCompile(`^/api/v1/teams/(\d+)(?:/members/([^/]+))?$`)
|
||||
userKeyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
|
||||
)
|
||||
|
||||
func (f *teamForge) teamByID(id string) string {
|
||||
for name, n := range f.ids {
|
||||
if fmt.Sprint(n) == id {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *teamForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var in map[string]any
|
||||
_ = json.Unmarshal(body, &in)
|
||||
reply := func(code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
p := r.URL.Path
|
||||
if m := userKeyRe.FindStringSubmatch(p); m != nil {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
out := []map[string]any{}
|
||||
for _, k := range f.keys[m[1]] {
|
||||
out = append(out, map[string]any{"id": k.ID, "title": k.Title, "key": k.Key})
|
||||
}
|
||||
reply(200, out)
|
||||
case http.MethodPost:
|
||||
f.next++
|
||||
ro, _ := in["read_only"].(bool)
|
||||
f.keys[m[1]] = append(f.keys[m[1]], forgeKey{ID: f.next, Title: in["title"].(string), Key: in["key"].(string), ReadOnly: ro})
|
||||
reply(201, map[string]any{"id": f.next})
|
||||
case http.MethodDelete:
|
||||
var kept []forgeKey
|
||||
for _, k := range f.keys[m[1]] {
|
||||
if fmt.Sprint(k.ID) != m[2] {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
f.keys[m[1]] = kept
|
||||
reply(204, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if m := teamRe.FindStringSubmatch(p); m != nil {
|
||||
name := f.teamByID(m[1])
|
||||
switch {
|
||||
case name == "":
|
||||
reply(404, map[string]any{"message": "no team"})
|
||||
case r.Method == http.MethodPatch && m[2] == "":
|
||||
f.teams[name] = in
|
||||
reply(200, map[string]any{})
|
||||
case r.Method == http.MethodPut:
|
||||
for _, u := range f.members[name] {
|
||||
if u == m[2] {
|
||||
reply(204, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
f.members[name] = append(f.members[name], m[2])
|
||||
reply(204, nil)
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"):
|
||||
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
|
||||
reply(404, map[string]any{"message": "no"})
|
||||
return
|
||||
}
|
||||
reply(200, map[string]any{"active": true})
|
||||
case r.Method == http.MethodPost && p == "/api/v1/admin/users":
|
||||
f.users[in["username"].(string)] = true
|
||||
reply(201, map[string]any{})
|
||||
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet:
|
||||
out := []map[string]any{}
|
||||
for name, id := range f.ids {
|
||||
out = append(out, map[string]any{"id": id, "name": name})
|
||||
}
|
||||
reply(200, out)
|
||||
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodPost:
|
||||
f.next++
|
||||
name := in["name"].(string)
|
||||
f.ids[name], f.teams[name] = f.next, in
|
||||
reply(201, map[string]any{"id": f.next})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *teamForge) titles(user string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []string{}
|
||||
for _, k := range f.keys[user] {
|
||||
out = append(out, k.Title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type teamRig struct {
|
||||
t *testing.T
|
||||
bus *fakeBus
|
||||
forge *teamForge
|
||||
svc *Service
|
||||
runner *fakeRunner
|
||||
}
|
||||
|
||||
func newTeamRig(t *testing.T) *teamRig {
|
||||
t.Helper()
|
||||
forge := newTeamForge()
|
||||
srv := httptest.NewServer(forge)
|
||||
t.Cleanup(srv.Close)
|
||||
dir := t.TempDir()
|
||||
pw, host, origins := filepath.Join(dir, "pw"), filepath.Join(dir, "host"), filepath.Join(dir, "origins")
|
||||
for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} {
|
||||
if err := os.WriteFile(p, []byte(c), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runner := &fakeRunner{out: scanned}
|
||||
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
||||
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
|
||||
svc.loaded = true
|
||||
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"] = true, true
|
||||
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
||||
}
|
||||
|
||||
func (r *teamRig) pass() []provider.Outcome {
|
||||
r.t.Helper()
|
||||
out, err := r.svc.Provider(r.bus).Pass(context.Background())
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bind makes module on node bind gitea's forge-account in the assignments mirror.
|
||||
func (r *teamRig) bind(node, module string) {
|
||||
r.bus.set(provider.AssignmentsBucket, node+"."+module, []byte(fmt.Sprintf(
|
||||
`{"node":%q,"module":%q,"provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s"}]}}`,
|
||||
node, module, strings.ReplaceAll(node+"_"+module, "-", "_"))))
|
||||
}
|
||||
|
||||
func (r *teamRig) unbind(node, module string) {
|
||||
r.bus.mu.Lock()
|
||||
delete(r.bus.buckets[provider.AssignmentsBucket], node+"."+module)
|
||||
r.bus.mu.Unlock()
|
||||
}
|
||||
|
||||
// key reports a key in module's state keys, of the kind and for the account given.
|
||||
func (r *teamRig) key(module, node, account, kind, public string) {
|
||||
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": kind, "public": public,
|
||||
"fingerprint": "SHA256:x", "at": "2026-10-20T10:00:00Z"})
|
||||
r.svc.SourceKeyChanged(module, node+"."+account, b)
|
||||
}
|
||||
|
||||
const (
|
||||
pubGo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
|
||||
pubNode = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"
|
||||
pubOps = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO"
|
||||
)
|
||||
|
||||
// 1481/AC1: an entry from mesh-build-go on ace: the forge user nox-builder and the team builders are ensured, the team's
|
||||
// units are code, read only, on every repository, and the entry's values name nox-builder. The key is registered
|
||||
// read-only under its title.
|
||||
func TestABuildModulesEntryIsServedAsNoxBuilderInAReadOnlyTeam(t *testing.T) {
|
||||
r := newTeamRig(t)
|
||||
r.bind("ace", "mesh-build-go")
|
||||
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
||||
r.pass()
|
||||
|
||||
if !r.forge.users["nox-builder"] || r.forge.users["nox-agent"] {
|
||||
t.Errorf("users = %v, want nox-builder alone", r.forge.users)
|
||||
}
|
||||
shape := r.forge.teams["builders"]
|
||||
if shape == nil {
|
||||
t.Fatalf("no team builders: %v", r.forge.teams)
|
||||
}
|
||||
units, _ := json.Marshal(shape["units_map"])
|
||||
if shape["permission"] != "read" || shape["includes_all_repositories"] != true || shape["can_create_org_repo"] != false || string(units) != `{"repo.code":"read"}` {
|
||||
t.Errorf("the team's shape = %v (units %s), want code read on every repository and no other right", shape, units)
|
||||
}
|
||||
if got := strings.Join(r.forge.members["builders"], ","); got != "nox-builder" || len(r.forge.members["agents"]) != 0 {
|
||||
t.Errorf("members = %v", r.forge.members)
|
||||
}
|
||||
keys := r.forge.keys["nox-builder"]
|
||||
if len(keys) != 1 || keys[0].Title != "mesh:ace.mesh-build-go" || !keys[0].ReadOnly {
|
||||
t.Errorf("nox-builder's keys = %+v, want one read-only key titled mesh:ace.mesh-build-go", keys)
|
||||
}
|
||||
e := r.bus.entry(t, "ace.mesh-build-go.forge-account")
|
||||
if e.Values["user"] != "nox-builder" || e.Values["ssh_alias"] != "mesh-forge" || e.Values["host_key"] != "ssh-ed25519 AAAAC3HOSTKEY" {
|
||||
t.Errorf("values = %v", e.Values)
|
||||
}
|
||||
}
|
||||
|
||||
// 1481/AC2: an entry from ssh-client is what it was: nox-agent, the agents team, a key that may write. (The existing
|
||||
// tests of the provider pass unchanged.)
|
||||
func TestAnSshClientEntryIsStillTheAgentsWithAKeyThatMayWrite(t *testing.T) {
|
||||
r := newTeamRig(t)
|
||||
// the agents team exists, as the forge has it
|
||||
r.forge.ids["agents"], r.forge.teams["agents"] = 5, map[string]any{}
|
||||
r.bind("g14", "ssh-client")
|
||||
r.key("ssh-client", "g14", "nox", "machine", pubA+" nox@g14 (mesh)")
|
||||
r.pass()
|
||||
if e := r.bus.entry(t, "g14.ssh-client.forge-account"); e.Values["user"] != "nox-agent" {
|
||||
t.Errorf("values = %v", e.Values)
|
||||
}
|
||||
keys := r.forge.keys["nox-agent"]
|
||||
if len(keys) != 1 || keys[0].Title != "mesh:g14.nox" || keys[0].ReadOnly {
|
||||
t.Errorf("nox-agent's keys = %+v, want one key that may write", keys)
|
||||
}
|
||||
if r.forge.teams["builders"] != nil || r.forge.users["nox-builder"] {
|
||||
t.Errorf("a machine's entry made the builders' account or team")
|
||||
}
|
||||
if got := strings.Join(r.forge.members["agents"], ","); got != "nox-agent" {
|
||||
t.Errorf("agents = %v", r.forge.members)
|
||||
}
|
||||
}
|
||||
|
||||
// 1481/AC3: a builder entry gone: only the keys titled mesh:<node>.mesh-build-go are removed; the user and the team
|
||||
// stay, and so do the other build module's key on that machine and a key of the operator's.
|
||||
func TestABuilderEntryGoneRemovesItsOwnKeyAndNothingElse(t *testing.T) {
|
||||
r := newTeamRig(t)
|
||||
r.bind("ace", "mesh-build-go")
|
||||
r.bind("ace", "mesh-build-node")
|
||||
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
||||
r.key("mesh-build-node", "ace", "mesh-build-node", "builder", pubNode+" mesh-build-node@ace (mesh)")
|
||||
r.pass()
|
||||
r.forge.mu.Lock()
|
||||
r.forge.keys["nox-builder"] = append(r.forge.keys["nox-builder"], forgeKey{ID: 99, Title: "my own", Key: pubOps})
|
||||
r.forge.mu.Unlock()
|
||||
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go,mesh:ace.mesh-build-node,my own" {
|
||||
t.Fatalf("before: %s", got)
|
||||
}
|
||||
r.unbind("ace", "mesh-build-go")
|
||||
r.pass()
|
||||
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-node,my own" {
|
||||
t.Errorf("after: %s, want only the go module's key gone", got)
|
||||
}
|
||||
if !r.forge.users["nox-builder"] || r.forge.teams["builders"] == nil || len(r.forge.members["builders"]) != 1 {
|
||||
t.Errorf("the user or the team went: users %v, members %v", r.forge.users, r.forge.members)
|
||||
}
|
||||
if r.bus.has("ace.mesh-build-go.forge-account") {
|
||||
t.Errorf("the entry stayed")
|
||||
}
|
||||
}
|
||||
|
||||
// A build module and ssh-client on one machine each have their own entry, keys, user and team.
|
||||
func TestABuildModuleAndSshClientOnOneMachineDoNotShareAnything(t *testing.T) {
|
||||
r := newTeamRig(t)
|
||||
r.forge.ids["agents"], r.forge.teams["agents"] = 5, map[string]any{}
|
||||
r.bind("ace", "ssh-client")
|
||||
r.bind("ace", "mesh-build-go")
|
||||
r.key("ssh-client", "ace", "nox", "machine", pubA+" nox@ace (mesh)")
|
||||
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
||||
r.pass()
|
||||
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "mesh:ace.nox" {
|
||||
t.Errorf("nox-agent: %s", got)
|
||||
}
|
||||
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go" {
|
||||
t.Errorf("nox-builder: %s", got)
|
||||
}
|
||||
// ssh-client's entry gone: nox-builder's key stays.
|
||||
r.unbind("ace", "ssh-client")
|
||||
r.pass()
|
||||
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go" {
|
||||
t.Errorf("nox-builder after ssh-client left: %s", got)
|
||||
}
|
||||
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "" {
|
||||
t.Errorf("nox-agent after ssh-client left: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A build module's entry waits for its own module's builder key: a machine key of the same node, a key of the wrong
|
||||
// kind in the build module's state, or the other build module's key is none.
|
||||
func TestABuildModulesEntryWaitsForItsOwnBuilderKey(t *testing.T) {
|
||||
r := newTeamRig(t)
|
||||
r.bind("ace", "mesh-build-go")
|
||||
r.key("ssh-client", "ace", "nox", "machine", pubA) // a machine's key
|
||||
r.key("mesh-build-go", "ace", "mesh-build-go", "machine", pubGo) // the wrong kind in the build module's state
|
||||
r.key("mesh-build-node", "ace", "mesh-build-node", "builder", pubNode) // the other build module's
|
||||
out := r.pass()
|
||||
if r.bus.has("ace.mesh-build-go.forge-account") || len(r.forge.users) != 0 || len(r.forge.teams) != 0 {
|
||||
t.Fatalf("something was made without a builder key: users %v teams %v", r.forge.users, r.forge.teams)
|
||||
}
|
||||
if len(out) != 1 || !strings.Contains(out[0].Why, "mesh-build-go") || !strings.Contains(out[0].Why, "builder key") {
|
||||
t.Errorf("the wait should name the module and the key: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
|
||||
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
|
||||
got, err := (&Service{}).valuesForUser("ace", module)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
// For each machine whose ssh-client binds the provision and has reported its public key (ssh-client's state `keys`),
|
||||
// it makes the machine's agent the one forge user nox-agent (in the agents team; ADR 0410), registers the key for
|
||||
// it, and serves where and as whom to reach the forge over ssh: the user, the forge machine's mesh name and port, its
|
||||
// host key and the two url roots. A public key is no secret, so the provision has no credential.
|
||||
// host key and the two url roots. A public key is no secret, so the provision has no credential. For each build
|
||||
// module that binds it and has reported its builder key (its own state `keys`), it does the same as the forge user
|
||||
// nox-builder, in the builders team that reads the code of every repository and nothing else, with the key read-only
|
||||
// (ADR 0466 §3).
|
||||
//
|
||||
// stdout is the MCP channel; it says what it says on stderr, and never the admin's password.
|
||||
package main
|
||||
@@ -22,6 +25,7 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"gitea/internal/forgeaccount"
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
@@ -42,11 +46,12 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stateKeys is ssh-client's state `keys` over the runtime (the manifest's `reads` grants it).
|
||||
type stateKeys struct{}
|
||||
// stateKeys is the state `keys` of one module over the runtime (the manifest's `reads` grants it): ssh-client's for
|
||||
// the machines' keys, a build module's for the builders' (ADR 0466 §3).
|
||||
type stateKeys struct{ module string }
|
||||
|
||||
func (stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||
return stdio.State("ssh-client.keys").Watch("", func(c stdio.StateChange) error {
|
||||
func (k stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||
return stdio.State(k.module+".keys").Watch("", func(c stdio.StateChange) error {
|
||||
handle(c.Key, c.Op, c.Value, c.Current)
|
||||
return nil
|
||||
})
|
||||
@@ -106,14 +111,18 @@ func main() {
|
||||
}
|
||||
}
|
||||
svc.OnKeys = func(string) { go pass() }
|
||||
go func() {
|
||||
if err := svc.WatchKeys(ctx, stateKeys{}); err != nil {
|
||||
say("the machines' keys are not watched: %v", err)
|
||||
return
|
||||
}
|
||||
say("watching the machines' keys")
|
||||
pass()
|
||||
}()
|
||||
// Each module's keys are watched on their own, and a module whose state never answers (a build module that is not
|
||||
// installed) holds back its own entries only.
|
||||
for _, module := range append([]string{SSHClient}, forgeaccount.BuildModules...) {
|
||||
go func() {
|
||||
if err := svc.WatchSource(ctx, module, stateKeys{module}); err != nil {
|
||||
say("the keys of %s are not watched: %v", module, err)
|
||||
return
|
||||
}
|
||||
say("watching the keys of %s", module)
|
||||
pass()
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
svc.KeepRunning(ctx, p.Run)
|
||||
}()
|
||||
|
||||
@@ -64,8 +64,11 @@ type Service struct {
|
||||
|
||||
mu sync.Mutex
|
||||
keys map[string]machineKey
|
||||
loaded bool // the machines' keys have been read: until then a node with none has not reported none
|
||||
known string // the forge's host key line, as the machines write it into known_hosts
|
||||
loaded bool // the machines' keys have been read: until then a node with none has not reported none
|
||||
// loadedFrom says of each build module's state that its keys have been read, as loaded says of ssh-client's: a build
|
||||
// module's state that never answers (the module is not installed) holds back its own entries and no other.
|
||||
loadedFrom map[string]bool
|
||||
known string // the forge's host key line, as the machines write it into known_hosts
|
||||
|
||||
bound provider.Bus // the bus the entries are written to (set by Provider)
|
||||
|
||||
@@ -76,14 +79,40 @@ type Service struct {
|
||||
|
||||
type machineKey struct {
|
||||
Node, Account, Kind, Public string
|
||||
// Source is the module whose state the key was read from: ssh-client's for a machine's, a build module's for a
|
||||
// builder's. Empty is ssh-client's.
|
||||
Source string
|
||||
}
|
||||
|
||||
// SSHClient is the module whose `keys` state holds the machines' keys (kind machine); the build modules' states hold
|
||||
// the builders' (kind builder, ADR 0466 §2).
|
||||
const SSHClient = "ssh-client"
|
||||
|
||||
// sourceOf is the module a stored key was read from.
|
||||
func (k machineKey) sourceOf() string {
|
||||
if k.Source == "" {
|
||||
return SSHClient
|
||||
}
|
||||
return k.Source
|
||||
}
|
||||
|
||||
// storageKey is the key a source's key is kept under: a machine's as it is, a builder's under its module's name.
|
||||
func storageKey(source, key string) string {
|
||||
if source == "" || source == SSHClient {
|
||||
return key
|
||||
}
|
||||
return source + "/" + key
|
||||
}
|
||||
|
||||
// kindOf is the kind of key a consumer module's entry is made for.
|
||||
func kindOf(module string) string { return forgeaccount.KindOfModule(module) }
|
||||
|
||||
// NewService is the service over its environment.
|
||||
func NewService(cfg Config) *Service {
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[gitea forge-account] "+f+"\n", a...) }
|
||||
}
|
||||
return &Service{cfg: cfg, keys: map[string]machineKey{}, letGo: map[string]bool{}, Pause: time.Sleep,
|
||||
return &Service{cfg: cfg, keys: map[string]machineKey{}, letGo: map[string]bool{}, loadedFrom: map[string]bool{}, Pause: time.Sleep,
|
||||
accounts: &forgeaccount.Accounts{Forge: cfg.Forge, Log: cfg.Log}}
|
||||
}
|
||||
|
||||
@@ -103,38 +132,67 @@ func (s *Service) Provider(bus provider.Bus) *provider.Provider {
|
||||
}
|
||||
|
||||
// errNoKey is a consumer whose machine has reported no key yet: nothing is made, and the client says it waits.
|
||||
type errNoKey struct{ node string }
|
||||
type errNoKey struct{ node, module string }
|
||||
|
||||
func (e errNoKey) Error() string {
|
||||
if kind := kindOf(e.module); kind == "builder" {
|
||||
return "no key from " + e.node + " yet: its " + e.module + " has not reported a builder key, so there is no " +
|
||||
"entry to make"
|
||||
}
|
||||
return "no key from " + e.node + " yet: its ssh-client has not reported a machine key, so there is no account " +
|
||||
"and no entry to make"
|
||||
}
|
||||
|
||||
// sourceOfModule is the module whose state holds the keys a consumer module's entry is made from: a build module's
|
||||
// own, ssh-client's for any other consumer.
|
||||
func sourceOfModule(module string) string {
|
||||
if kindOf(module) == "builder" {
|
||||
return module
|
||||
}
|
||||
return SSHClient
|
||||
}
|
||||
|
||||
// scopeFor is the title prefix of the keys one entry registered: every key of a machine for a machine's entry, one
|
||||
// build module's key for a builder's (nox-builder holds the keys of every build module of the mesh).
|
||||
func scopeFor(kind, node, module string) string {
|
||||
if kind == "builder" {
|
||||
return forgeaccount.BuilderScope(node, module)
|
||||
}
|
||||
return forgeaccount.MachineScope(node)
|
||||
}
|
||||
|
||||
// Make brings one consumer's account, its team membership and its keys into being, as the machine reported them. It
|
||||
// leaves what is as asked alone, so it is asked again freely.
|
||||
func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||
s.making.Lock()
|
||||
defer s.making.Unlock()
|
||||
node := c.Entry.Consumer
|
||||
user := forgeaccount.ForgeUser(node, "machine")
|
||||
module, kind := c.Entry.Module, kindOf(c.Entry.Module)
|
||||
user := forgeaccount.ForgeUser(node, kind)
|
||||
if user == "" {
|
||||
return fmt.Errorf("%q is not a machine's name an account can be named for", node)
|
||||
}
|
||||
source := sourceOfModule(module)
|
||||
s.mu.Lock()
|
||||
loaded := s.loaded
|
||||
if source != SSHClient {
|
||||
loaded = s.loadedFrom[source]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !loaded {
|
||||
// After a restart the first pass can come before the keys arrive: nothing is removed or deleted on that.
|
||||
return errors.New("the machines' keys are not read yet, so nothing is made, removed or deleted")
|
||||
return fmt.Errorf("the keys of %s are not read yet, so nothing is made, removed or deleted", source)
|
||||
}
|
||||
have := s.keysOf(node)
|
||||
have := s.keysFrom(source, node, kind)
|
||||
if len(have) == 0 {
|
||||
if err := s.releasePerNodeUser(ctx, node); err != nil {
|
||||
return err
|
||||
if kind == "machine" {
|
||||
if err := s.releasePerNodeUser(ctx, node); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A key the machine no longer reports is no longer wanted (ADR 0400 §2): the mesh's keys go, the operator's
|
||||
// stay, and the entry that served the machine goes with them.
|
||||
if _, err := s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.MachineScope(node)); err != nil {
|
||||
if _, err := s.accounts.RemoveKeysTitled(ctx, user, scopeFor(kind, node, module)); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.bound != nil {
|
||||
@@ -142,7 +200,7 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errNoKey{node}
|
||||
return errNoKey{node: node, module: module}
|
||||
}
|
||||
if _, err := s.hostName(); err != nil {
|
||||
return err
|
||||
@@ -154,20 +212,23 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||
if err := s.accounts.EnsureAccount(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.accounts.EnsureInTeam(ctx, user); err != nil {
|
||||
if err := s.accounts.EnsureInTeamOf(ctx, forgeaccount.TeamOfUser(user), user); err != nil {
|
||||
return err
|
||||
}
|
||||
var wanted []forgeaccount.WantedKey
|
||||
for _, k := range have {
|
||||
wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public})
|
||||
// A builder's key may read and never push (ADR 0466 §3): the key says it beside the team that does.
|
||||
wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public, ReadOnly: kind == "builder"})
|
||||
}
|
||||
// The forge holds one public key on one user only, so the key leaves agent-<node> before it joins nox-agent. That
|
||||
// is done here, after everything that can fail before it, so the window in which the machine's key is on no user
|
||||
// is only the span between these two calls; if the add fails, the next pass (at most ten minutes) puts it back.
|
||||
if err := s.releasePerNodeUser(ctx, node); err != nil {
|
||||
return err
|
||||
if kind == "machine" {
|
||||
if err := s.releasePerNodeUser(ctx, node); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.accounts.EnsureKeys(ctx, user, forgeaccount.MachineScope(node), wanted); err != nil {
|
||||
if err := s.accounts.EnsureKeys(ctx, user, scopeFor(kind, node, module), wanted); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -176,20 +237,25 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gone removes the keys the mesh registered for a machine that no longer binds the forge. The account stays: it may
|
||||
// Gone removes the keys the mesh registered for a machine (or, for a build module, the module's key on that machine)
|
||||
// that no longer binds the forge. The account stays: it may
|
||||
// hold what the machine's agent wrote, and the keys of the operator's own that were added by hand are not touched
|
||||
// (ADR 0399 §2).
|
||||
func (s *Service) Gone(ctx context.Context, e provider.Entry) error {
|
||||
s.making.Lock()
|
||||
defer s.making.Unlock()
|
||||
user := forgeaccount.ForgeUser(e.Consumer, "machine")
|
||||
kind := kindOf(e.Module)
|
||||
user := forgeaccount.ForgeUser(e.Consumer, kind)
|
||||
if user == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.releasePerNodeUser(ctx, e.Consumer); err != nil {
|
||||
return err
|
||||
if kind == "machine" {
|
||||
if err := s.releasePerNodeUser(ctx, e.Consumer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.MachineScope(e.Consumer))
|
||||
// A builder's entry gone removes that entry's key only: the user and the team serve the other machines.
|
||||
_, err := s.accounts.RemoveKeysTitled(ctx, user, scopeFor(kind, e.Consumer, e.Module))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -220,17 +286,24 @@ func (s *Service) releasePerNodeUser(ctx context.Context, node string) error {
|
||||
}
|
||||
|
||||
// KeyChanged takes in one machine key as the bus keeps it, and tells OnKeys its node.
|
||||
func (s *Service) KeyChanged(key string, value []byte) {
|
||||
if node := s.store(key, value); node != "" && s.OnKeys != nil {
|
||||
func (s *Service) KeyChanged(key string, value []byte) { s.SourceKeyChanged(SSHClient, key, value) }
|
||||
|
||||
// KeyGone forgets one machine key, and tells OnKeys its node.
|
||||
func (s *Service) KeyGone(key string) { s.SourceKeyGone(SSHClient, key) }
|
||||
|
||||
// SourceKeyChanged takes in one key of the state `keys` of the module source (ssh-client's, or a build module's), and
|
||||
// tells OnKeys its node.
|
||||
func (s *Service) SourceKeyChanged(source, key string, value []byte) {
|
||||
if node := s.storeFrom(source, key, value); node != "" && s.OnKeys != nil {
|
||||
s.OnKeys(node)
|
||||
}
|
||||
}
|
||||
|
||||
// KeyGone forgets one machine key, and tells OnKeys its node.
|
||||
func (s *Service) KeyGone(key string) {
|
||||
// SourceKeyGone forgets one key of the module source's state, and tells OnKeys its node.
|
||||
func (s *Service) SourceKeyGone(source, key string) {
|
||||
node, _, _ := strings.Cut(key, ".")
|
||||
s.mu.Lock()
|
||||
delete(s.keys, key)
|
||||
delete(s.keys, storageKey(source, key))
|
||||
s.mu.Unlock()
|
||||
if s.OnKeys != nil {
|
||||
s.OnKeys(node)
|
||||
@@ -240,24 +313,38 @@ func (s *Service) KeyGone(key string) {
|
||||
// store keeps a machine key that reads as one: its node and account the key's own, and a public half that is a
|
||||
// public key line and nothing private. It answers the node, or "" for a value it did not keep (and drops what was
|
||||
// kept under that key, since the key now says something else).
|
||||
func (s *Service) store(key string, value []byte) string {
|
||||
func (s *Service) store(key string, value []byte) string { return s.storeFrom(SSHClient, key, value) }
|
||||
|
||||
// storeFrom is store for a key of the module source's state.
|
||||
func (s *Service) storeFrom(source, key string, value []byte) string {
|
||||
node, account, _ := strings.Cut(key, ".")
|
||||
stored := storageKey(source, key)
|
||||
var k machineKey
|
||||
var raw struct {
|
||||
Node, Account, Kind, Public string
|
||||
}
|
||||
err := json.Unmarshal(value, &raw)
|
||||
k = machineKey(raw)
|
||||
k = machineKey{Node: raw.Node, Account: raw.Account, Kind: raw.Kind, Public: raw.Public}
|
||||
if source != SSHClient {
|
||||
k.Source = source
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) {
|
||||
if _, had := s.keys[key]; had {
|
||||
delete(s.keys, key)
|
||||
}
|
||||
s.cfg.Log("the key %s is not a machine's public key; it is not registered", key)
|
||||
if want := kindOf(source); err == nil && k.Kind != want && k.Node == node && k.Account == account {
|
||||
// Each state holds one kind: ssh-client's the machines', a build module's the builders' (ADR 0466 §2). The other
|
||||
// kind in either is not this entry's to serve, and is said.
|
||||
delete(s.keys, stored)
|
||||
s.cfg.Log("the key %s in the state keys of %s is of kind %q, not %q; it is ignored", key, source, k.Kind, want)
|
||||
return ""
|
||||
}
|
||||
s.keys[key] = k
|
||||
if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) {
|
||||
if _, had := s.keys[stored]; had {
|
||||
delete(s.keys, stored)
|
||||
}
|
||||
s.cfg.Log("the key %s of %s is not a public key that can be registered; it is not registered", key, source)
|
||||
return ""
|
||||
}
|
||||
s.keys[stored] = k
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -267,12 +354,15 @@ func publicKeyLine(line string) bool {
|
||||
}
|
||||
|
||||
// keysOf are the machine keys one node reported, by key.
|
||||
func (s *Service) keysOf(node string) []machineKey {
|
||||
func (s *Service) keysOf(node string) []machineKey { return s.keysFrom(SSHClient, node, "machine") }
|
||||
|
||||
// keysFrom are the keys of the kind that one node reported in the module source's state, by key.
|
||||
func (s *Service) keysFrom(source, node, kind string) []machineKey {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var names []string
|
||||
for key, k := range s.keys {
|
||||
if k.Node == node && k.Kind == "machine" && strings.HasPrefix(key, node+".") {
|
||||
if k.Node == node && k.Kind == kind && k.sourceOf() == source && strings.HasPrefix(key, storageKey(source, node+".")) {
|
||||
names = append(names, key)
|
||||
}
|
||||
}
|
||||
@@ -284,27 +374,37 @@ func (s *Service) keysOf(node string) []machineKey {
|
||||
return out
|
||||
}
|
||||
|
||||
// WatchKeys keeps the machines' keys as the bus holds them, asking again until the runtime answers (the channel to it
|
||||
// opens as the bundle starts, and the state of ssh-client may arrive after this one). A change after the first
|
||||
// values is told to OnKeys; the first values are not, since one pass answers them all.
|
||||
// WatchKeys keeps the machines' keys as the bus holds them (ssh-client's state `keys`), asking again until the runtime
|
||||
// answers (the channel to it opens as the bundle starts, and the state of ssh-client may arrive after this one). A
|
||||
// change after the first values is told to OnKeys; the first values are not, since one pass answers them all.
|
||||
func (s *Service) WatchKeys(ctx context.Context, src KeySource) error {
|
||||
return s.WatchSource(ctx, SSHClient, src)
|
||||
}
|
||||
|
||||
// WatchSource is WatchKeys for the state `keys` of any module whose keys the provider registers: ssh-client's, or a
|
||||
// build module's (ADR 0466 §3). Once its first values are in, the entries made from them are no longer held back.
|
||||
func (s *Service) WatchSource(ctx context.Context, source string, src KeySource) error {
|
||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := src.Watch(func(key, op string, value []byte, current bool) {
|
||||
switch {
|
||||
case op != "put" && current:
|
||||
case op != "put":
|
||||
s.KeyGone(key)
|
||||
s.SourceKeyGone(source, key)
|
||||
case current:
|
||||
s.store(key, value)
|
||||
s.storeFrom(source, key, value)
|
||||
default:
|
||||
s.KeyChanged(key, value)
|
||||
s.SourceKeyChanged(source, key, value)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
// The watch answers once the current values are all handled (stdio KeptState.Watch).
|
||||
s.mu.Lock()
|
||||
s.loaded = true
|
||||
if source == SSHClient {
|
||||
s.loaded = true
|
||||
} else {
|
||||
s.loadedFrom[source] = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -312,7 +412,7 @@ func (s *Service) WatchKeys(ctx context.Context, src KeySource) error {
|
||||
if attempt < len(waits) {
|
||||
pause = waits[attempt]
|
||||
}
|
||||
s.cfg.Log("the machines' keys cannot be watched yet (%v); asking again in %s", err, pause)
|
||||
s.cfg.Log("the keys of %s cannot be watched yet (%v); asking again in %s", source, err, pause)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -377,9 +477,18 @@ func knownHostLine(scanned, host string, port int) (string, error) {
|
||||
return "", errors.New("the forge's sshd gave no ed25519 host key")
|
||||
}
|
||||
|
||||
// valuesForUser is the forge user a consumer module's entry is served as.
|
||||
func (s *Service) valuesForUser(node, module string) (string, error) {
|
||||
user := forgeaccount.ForgeUser(node, kindOf(module))
|
||||
if user == "" {
|
||||
return "", fmt.Errorf("%q is not a machine's name an account can be named for", node)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// valuesFor are the values served to one machine's consumer (ADR 0400 §3, §4).
|
||||
func (s *Service) valuesFor(node string) (map[string]any, error) {
|
||||
user := forgeaccount.ForgeUser(node, "machine")
|
||||
func (s *Service) valuesFor(node, module string) (map[string]any, error) {
|
||||
user := forgeaccount.ForgeUser(node, kindOf(module))
|
||||
host, err := s.hostName()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -421,7 +530,7 @@ func (b *valuesBus) Put(ctx context.Context, bucket, key string, value []byte) e
|
||||
return err
|
||||
}
|
||||
if e.Refused == "" {
|
||||
extra, err := b.s.valuesFor(e.Consumer)
|
||||
extra, err := b.s.valuesFor(e.Consumer, e.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1482 (novox/hq ADR 0466 §3): the provider watches the build modules' state `keys` beside ssh-client's, and
|
||||
// registers each builder entry read-only on nox-builder.
|
||||
|
||||
func keyJSON(node, account, kind, public string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": kind, "public": public,
|
||||
"fingerprint": "SHA256:x", "at": "2026-10-20T10:00:00Z"})
|
||||
return b
|
||||
}
|
||||
|
||||
// watched is a team rig whose keys come through watches of the modules' states, as in the running provider.
|
||||
type watched struct {
|
||||
*teamRig
|
||||
sources map[string]*fakeKeys
|
||||
passes *int
|
||||
logs *[]string
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
func newWatched(t *testing.T) *watched {
|
||||
r := newTeamRig(t)
|
||||
r.svc.loaded, r.svc.loadedFrom = false, map[string]bool{}
|
||||
r.svc.Pause = func(time.Duration) {}
|
||||
n, logs, mu := 0, []string{}, &sync.Mutex{}
|
||||
r.svc.OnKeys = func(string) { n++ }
|
||||
r.svc.cfg.Log = func(f string, a ...any) { mu.Lock(); logs = append(logs, fmt.Sprintf(f, a...)); mu.Unlock() }
|
||||
w := &watched{teamRig: r, sources: map[string]*fakeKeys{}, passes: &n, logs: &logs, mu: mu}
|
||||
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node"} {
|
||||
w.sources[module] = &fakeKeys{}
|
||||
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *watched) said() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return strings.Join(*w.logs, "\n")
|
||||
}
|
||||
|
||||
// 1482/AC1: a mesh-build-go.keys entry ace.mesh-build-go of kind builder: its public half is registered read-only on
|
||||
// nox-builder titled mesh:ace.mesh-build-go.
|
||||
func TestABuilderEntryOfABuildModulesStateIsRegisteredReadOnlyOnNoxBuilder(t *testing.T) {
|
||||
w := newWatched(t)
|
||||
w.bind("ace", "mesh-build-go")
|
||||
w.sources["mesh-build-go"].send("ace.mesh-build-go", "put", keyJSON("ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)"), true)
|
||||
w.pass()
|
||||
keys := w.forge.keys["nox-builder"]
|
||||
if len(keys) != 1 || keys[0].Title != "mesh:ace.mesh-build-go" || !keys[0].ReadOnly || keys[0].Key != pubGo+" mesh-build-go@ace (mesh)" {
|
||||
t.Errorf("nox-builder's keys = %+v", keys)
|
||||
}
|
||||
if !w.svc.loadedFrom["mesh-build-go"] {
|
||||
t.Errorf("the state was watched and its keys are not counted as read")
|
||||
}
|
||||
}
|
||||
|
||||
// 1482/AC2: that entry changed: the old key is removed and the new one registered; deleted: the key is removed and no key
|
||||
// titled otherwise is touched.
|
||||
func TestAChangedBuilderEntryReplacesItsKeyAndADeletedOneRemovesIt(t *testing.T) {
|
||||
w := newWatched(t)
|
||||
w.bind("ace", "mesh-build-go")
|
||||
src := w.sources["mesh-build-go"]
|
||||
src.send("ace.mesh-build-go", "put", keyJSON("ace", "mesh-build-go", "builder", pubGo), true)
|
||||
w.pass()
|
||||
w.forge.mu.Lock()
|
||||
w.forge.keys["nox-builder"] = append(w.forge.keys["nox-builder"], forgeKey{ID: 99, Title: "my own", Key: pubOps})
|
||||
w.forge.mu.Unlock()
|
||||
|
||||
before := *w.passes
|
||||
src.send("ace.mesh-build-go", "put", keyJSON("ace", "mesh-build-go", "builder", pubNode), false)
|
||||
if *w.passes != before+1 {
|
||||
t.Errorf("a change asked for %d passes, want 1", *w.passes-before)
|
||||
}
|
||||
w.pass()
|
||||
got := w.forge.keys["nox-builder"]
|
||||
if len(got) != 2 || got[0].Title != "my own" || got[1].Title != "mesh:ace.mesh-build-go" || got[1].Key != pubNode || !got[1].ReadOnly {
|
||||
t.Errorf("after the change: %+v", got)
|
||||
}
|
||||
|
||||
src.send("ace.mesh-build-go", "delete", nil, false)
|
||||
w.pass()
|
||||
if got := strings.Join(w.forge.titles("nox-builder"), ","); got != "my own" {
|
||||
t.Errorf("after the delete: %s, want only the operator's key", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1482/AC3: an entry of kind machine in a build module's state, or of kind builder in ssh-client's, is ignored and
|
||||
// said in the provider's log.
|
||||
func TestAKeyOfTheWrongKindForItsStateIsIgnoredAndSaid(t *testing.T) {
|
||||
w := newWatched(t)
|
||||
w.bind("ace", "mesh-build-go")
|
||||
w.bind("g14", "ssh-client")
|
||||
w.sources["mesh-build-go"].send("ace.mesh-build-go", "put", keyJSON("ace", "mesh-build-go", "machine", pubGo), true)
|
||||
w.sources["ssh-client"].send("g14.nox", "put", keyJSON("g14", "nox", "builder", pubA), true)
|
||||
w.pass()
|
||||
if len(w.forge.users) != 0 || w.bus.has("ace.mesh-build-go.forge-account") || w.bus.has("g14.ssh-client.forge-account") {
|
||||
t.Errorf("a key of the wrong kind was served: users %v", w.forge.users)
|
||||
}
|
||||
said := w.said()
|
||||
for _, want := range []string{`ace.mesh-build-go in the state keys of mesh-build-go is of kind "machine", not "builder"`, `g14.nox in the state keys of ssh-client is of kind "builder", not "machine"`} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("the log lacks %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A build module's state that never answers holds back its own entries, never a machine's.
|
||||
func TestABuildModulesStateThatNeverAnswersHoldsBackOnlyItsOwnEntries(t *testing.T) {
|
||||
w := newWatched(t)
|
||||
w.svc.loadedFrom = map[string]bool{} // mesh-build-go's watch has not answered
|
||||
w.bind("ace", "mesh-build-go")
|
||||
w.bind("g14", "ssh-client")
|
||||
w.sources["ssh-client"].send("g14.nox", "put", keyJSON("g14", "nox", "machine", pubA+" nox@g14 (mesh)"), true)
|
||||
out := w.pass()
|
||||
if !w.bus.has("g14.ssh-client.forge-account") {
|
||||
t.Errorf("a machine's entry waited for a build module's state")
|
||||
}
|
||||
waits := 0
|
||||
for _, o := range out {
|
||||
if strings.Contains(o.Key, "mesh-build-go") && strings.Contains(o.Why, "keys of mesh-build-go are not read yet") {
|
||||
waits++
|
||||
}
|
||||
}
|
||||
if waits != 1 {
|
||||
t.Errorf("outcomes = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 1482/AC4 (the provider's half): the manifest reads the states of both build modules beside ssh-client's and the
|
||||
// controller's assignments; the provider watches exactly the modules the code names as builders.
|
||||
func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Reads []string `json:"reads"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys"} {
|
||||
if !slices.Contains(m.Reads, state) {
|
||||
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR
|
||||
// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write
|
||||
// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says
|
||||
// what an agent's account is.
|
||||
// what an agent's account is. It also makes the one account of the build modules' keys, nox-builder, in the builders
|
||||
// team that reads the code of every repository and nothing else (ADR 0466 §3; issue 1481).
|
||||
package forgeaccount
|
||||
|
||||
import (
|
||||
@@ -26,8 +27,23 @@ const (
|
||||
TeamName = "agents"
|
||||
)
|
||||
|
||||
// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the
|
||||
// BuildersTeamName is the team of the build modules' account (ADR 0466 §3).
|
||||
const BuildersTeamName = "builders"
|
||||
|
||||
// Team is a team of the organisation as the mesh keeps it: its name and the shape it is put back to.
|
||||
type Team struct {
|
||||
Name string
|
||||
Shape func() map[string]any
|
||||
}
|
||||
|
||||
// AgentsTeam is the team of the machines' agents: write on code, issues and pull requests of every repository of the
|
||||
// organisation and no more, the shape of the forge tools' team (tools-account.ts).
|
||||
var AgentsTeam = Team{Name: TeamName, Shape: teamShape}
|
||||
|
||||
// BuildersTeam is the team of the build modules' account: it may read the code of every repository of the organisation
|
||||
// and has no other right (ADR 0466 §3).
|
||||
var BuildersTeam = Team{Name: BuildersTeamName, Shape: buildersTeamShape}
|
||||
|
||||
func teamShape() map[string]any {
|
||||
return map[string]any{
|
||||
"name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||
@@ -35,6 +51,13 @@ func teamShape() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func buildersTeamShape() map[string]any {
|
||||
return map[string]any{
|
||||
"name": BuildersTeamName, "permission": "read", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||
"units_map": map[string]string{"repo.code": "read"},
|
||||
}
|
||||
}
|
||||
|
||||
// User is gitea's account as the admin reads it.
|
||||
type User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
@@ -87,35 +110,41 @@ func (m *Accounts) EnsureAccount(ctx context.Context, account string) error {
|
||||
// EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the
|
||||
// account to it; an account already in it stays (ADR 0398 §1, issue 948).
|
||||
func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error {
|
||||
id, err := m.teamID(ctx)
|
||||
return m.EnsureInTeamOf(ctx, AgentsTeam, account)
|
||||
}
|
||||
|
||||
// EnsureInTeamOf is EnsureInTeam for any team of the mesh's: it makes the team when there is none, puts a team that is
|
||||
// there back to its shape, and adds the account to it.
|
||||
func (m *Accounts) EnsureInTeamOf(ctx context.Context, team Team, account string) error {
|
||||
id, err := m.teamID(ctx, team.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id != 0 {
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil {
|
||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err)
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), team.Shape(), nil); err != nil {
|
||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", team.Name, err)
|
||||
}
|
||||
} else {
|
||||
var made struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil {
|
||||
return fmt.Errorf("the team %s could not be made: %w", TeamName, err)
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", team.Shape(), &made); err != nil {
|
||||
return fmt.Errorf("the team %s could not be made: %w", team.Name, err)
|
||||
}
|
||||
id = made.ID
|
||||
m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg)
|
||||
m.say("made the forge team %s in %s", team.Name, TeamOrg)
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("the forge answered no id for the team %s", TeamName)
|
||||
return fmt.Errorf("the forge answered no id for the team %s", team.Name)
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil {
|
||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err)
|
||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, team.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// teamID is the agents team's id, or 0 when there is none.
|
||||
func (m *Accounts) teamID(ctx context.Context) (int64, error) {
|
||||
// teamID is the id of the team of that name, or 0 when there is none.
|
||||
func (m *Accounts) teamID(ctx context.Context, name string) (int64, error) {
|
||||
var id int64
|
||||
for page := 1; page <= 20 && id == 0; page++ {
|
||||
var teams []struct {
|
||||
@@ -126,7 +155,7 @@ func (m *Accounts) teamID(ctx context.Context) (int64, error) {
|
||||
return 0, fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.Name == TeamName {
|
||||
if t.Name == name {
|
||||
id = t.ID
|
||||
}
|
||||
}
|
||||
@@ -160,16 +189,49 @@ var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
// AgentUser is the one forge user of every machine's agents (ADR 0410 §1), in the agents write team.
|
||||
const AgentUser = "nox-agent"
|
||||
|
||||
// ForgeUser is the forge user of a machine's key of the given kind (ADR 0410 §1): `nox-agent` for every machine
|
||||
// and account (kind machine). A kind not built yet has none, and the answer is empty. The machine's name must still
|
||||
// be one an account could be named for.
|
||||
// BuilderUser is the one forge user of every build module's key (ADR 0466 §3), in the builders team: it reads and
|
||||
// pushes nothing.
|
||||
const BuilderUser = "nox-builder"
|
||||
|
||||
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
|
||||
// the account each runs as (ADR 0466 §1).
|
||||
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
|
||||
|
||||
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
|
||||
// other (ssh-client's).
|
||||
func KindOfModule(module string) string {
|
||||
for _, m := range BuildModules {
|
||||
if m == module {
|
||||
return "builder"
|
||||
}
|
||||
}
|
||||
return "machine"
|
||||
}
|
||||
|
||||
// ForgeUser is the forge user of a key of the given kind (ADR 0410 §1, ADR 0466 §3): `nox-agent` for every machine
|
||||
// and account (kind machine), `nox-builder` for every build module (kind builder). A kind not built yet has none, and
|
||||
// the answer is empty. The machine's name must still be one an account could be named for.
|
||||
func ForgeUser(node, kind string) string {
|
||||
if kind == "machine" && NodeName.MatchString(node) {
|
||||
if !NodeName.MatchString(node) {
|
||||
return ""
|
||||
}
|
||||
switch kind {
|
||||
case "machine":
|
||||
return AgentUser
|
||||
case "builder":
|
||||
return BuilderUser
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TeamOfUser is the team a forge user of the mesh is in.
|
||||
func TeamOfUser(user string) Team {
|
||||
if user == BuilderUser {
|
||||
return BuildersTeam
|
||||
}
|
||||
return AgentsTeam
|
||||
}
|
||||
|
||||
// NodeAccount is the forge account the token tool (cmd/agent-token) keeps for a node's agent: `agent-<node>` (ADR
|
||||
// 0365 §1). It is not the forge user of the machine's ssh key (ForgeUser), and its behaviour is unchanged.
|
||||
func NodeAccount(node string) string { return AccountPrefix + node }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package forgeaccount
|
||||
|
||||
import "testing"
|
||||
|
||||
// Issue 1481 (novox/hq ADR 0466 §3): the build modules' forge user, team and key scope.
|
||||
func TestTheBuildModulesHaveOneForgeUserAndTheMachinesKeepTheirs(t *testing.T) {
|
||||
for kind, want := range map[string]string{"machine": "nox-agent", "builder": "nox-builder", "session": ""} {
|
||||
if got := ForgeUser("ace", kind); got != want {
|
||||
t.Errorf("ForgeUser(ace, %s) = %q, want %q", kind, got, want)
|
||||
}
|
||||
}
|
||||
if ForgeUser("Not A Node", "builder") != "" {
|
||||
t.Errorf("a name no account could be named for got a user")
|
||||
}
|
||||
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "ssh-client": "machine", "": "machine"} {
|
||||
if got := KindOfModule(module); got != want {
|
||||
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
|
||||
}
|
||||
}
|
||||
if TeamOfUser(BuilderUser).Name != "builders" || TeamOfUser(AgentUser).Name != "agents" {
|
||||
t.Errorf("teams: %s and %s", TeamOfUser(BuilderUser).Name, TeamOfUser(AgentUser).Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A build module's scope names the module, so one module's pass does not remove the other's key on the same machine;
|
||||
// the machine's scope is every key of the machine.
|
||||
func TestABuilderScopeNamesTheModule(t *testing.T) {
|
||||
if got := BuilderScope("ace", "mesh-build-go"); got != "mesh:ace.mesh-build-go" {
|
||||
t.Errorf("scope = %q", got)
|
||||
}
|
||||
if BuilderScope("ace", "mesh-build-go") == BuilderScope("ace", "mesh-build-node") {
|
||||
t.Errorf("two build modules share a scope")
|
||||
}
|
||||
if got := KeyTitle("ace", "mesh-build-go"); got != BuilderScope("ace", "mesh-build-go") {
|
||||
t.Errorf("the key's title %q is not inside the scope", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The builders team reads the code of every repository of the organisation and has no other right.
|
||||
func TestTheBuildersTeamReadsCodeOnly(t *testing.T) {
|
||||
s := BuildersTeam.Shape()
|
||||
units, _ := s["units_map"].(map[string]string)
|
||||
if s["permission"] != "read" || s["includes_all_repositories"] != true || s["can_create_org_repo"] != false || len(units) != 1 || units["repo.code"] != "read" {
|
||||
t.Errorf("shape = %v", s)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type WantedKey struct {
|
||||
Title string
|
||||
// Key is the public key line: type, base64 and a comment.
|
||||
Key string
|
||||
// ReadOnly registers the key as one that may not push (a builder's, ADR 0466 §3).
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// Key is a key the forge holds for an account.
|
||||
@@ -56,10 +58,10 @@ func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) {
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// AddKey registers a key for the account, one it may write with.
|
||||
// AddKey registers a key for the account, one it may write with unless the key is wanted read-only.
|
||||
func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error {
|
||||
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys",
|
||||
map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil)
|
||||
map[string]any{"title": k.Title, "key": k.Key, "read_only": k.ReadOnly}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err)
|
||||
}
|
||||
@@ -78,6 +80,11 @@ func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error {
|
||||
// MachineScope is the title prefix of one machine's keys, `mesh:<node>.`: what a pass for that machine may remove.
|
||||
func MachineScope(node string) string { return TitlePrefix + node + "." }
|
||||
|
||||
// BuilderScope is the title prefix of one build module's key on one machine, `mesh:<node>.<module>`: what a pass for
|
||||
// that module's entry may remove. It names the module, since nox-builder holds the keys of both build modules of a
|
||||
// machine and one's pass must not remove the other's.
|
||||
func BuilderScope(node, module string) string { return TitlePrefix + node + "." + module }
|
||||
|
||||
// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered
|
||||
// for one machine (title starting scope, `mesh:<node>.`) that are no longer wanted. One forge user holds every
|
||||
// machine's keys (ADR 0410 §2), so a key under another machine's title is never removed here. A key whose title does not start `mesh:` is the operator's and
|
||||
|
||||
@@ -540,6 +540,8 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"ssh-client.keys"
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# mesh-build-go — the Go build module
|
||||
|
||||
Design 47, release R4c-3 (novox/hq ADR 0405, ADR 0466 §1, ADR 0469, ADR 0470). The module that builds and checks Go
|
||||
code on a build machine, as an account of its own (`runs-as`, no login shell, home at mode 0700), with the
|
||||
toolchain installed as the packages it declares (`go`, `git`, `openssh`, `postgresql`) and the capability
|
||||
`user-namespaces`. It claims no seat: it is assigned to the build machines (ADR 0469 §6) and runs dark until the
|
||||
builder uses it.
|
||||
|
||||
## The recipe
|
||||
|
||||
`cmd/mesh-build-go/recipe.go` names the one Go version the module builds with (`RecipeGo`), the postgres major
|
||||
version the integration level tests with, and the recipe's own version. The recipe version is a hash of those and of
|
||||
the nats-server library's version, read from the program's build information, so a change of any of them moves it.
|
||||
|
||||
## The guard and the health
|
||||
|
||||
At start, every ten minutes, before every step and at every health look, the module reads the installed Go
|
||||
(`go version`) and writes it with the recipe version to `<node>.go` of its `toolchains` state. A machine whose Go
|
||||
is not the recipe's refuses every step, the step ending `refused` on its key in `steps` with both versions named,
|
||||
and `build_go_health` (the module's `runtime-health`) answers unhealthy with the same text, so the controller raises
|
||||
the machine's condition. Moving to a new Go is a change of `RecipeGo`.
|
||||
|
||||
## Tools
|
||||
|
||||
`build_go_status`, `build_go_health` and `build_go_fetch` are served now; the other step tools (`build_go_test`,
|
||||
`build_go_digest`, `build_go_build`) are added by their own issues to `StepTools` and run behind the guard: a long
|
||||
step answers at once with its id and ends on `<node>.<id>` of `steps` (ADR 0469 §2); the digest answers in its call.
|
||||
|
||||
## Fetch
|
||||
|
||||
`build_go_fetch {repository, commit, ref?}` fetches the commit over SSH as the module's account (`mesh-forge` is the
|
||||
forge-account provision's ssh name, so the account's own ssh files and git rewrite are used) into a workspace of the
|
||||
work directory, then runs `go mod download` and `go mod verify` in every directory with a `go.mod` that does not vendor,
|
||||
with `GOTOOLCHAIN=local`, `GOFLAGS=-mod=mod` and the module's cache directory. Every command runs with an
|
||||
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes. Workspaces older than a day
|
||||
are removed.
|
||||
|
||||
## Test
|
||||
|
||||
`build_go_test {workspace, level, packages?}` runs the tests of a fetched workspace by Go's convention, one level at a
|
||||
time. The unit level is `go test -json` over every package without the `integration` build tag, in each directory
|
||||
with a `go.mod`, judged by its one bound: its running time, that of the slowest package (compile not counted), within
|
||||
five seconds. The step ends passed when the level ran, and its result says whether the level did: `passed`, the
|
||||
packages with their times, the tests that failed with their first lines of output, the five slowest tests, and `why`
|
||||
when it failed (a failed test, a build that failed) or ran over a bound. `passed` is the tests' own verdict: a level over its bound still passes and says `over_bound` (and `over_which`, setup or run) with the slowest tests, and the checker judges the bounds (ADR 0453 §9). A step ends failed only when go test could not
|
||||
run. A repository with no tests at the level has none, and the result says so: that is a pass. `testdata/captured` holds
|
||||
the output of the real go for the fixture repositories in `testdata/repos`; the unit tests read it, and the
|
||||
integration-tagged test runs the real go on the fixtures.
|
||||
|
||||
## The integration level
|
||||
|
||||
`build_go_test` with `level: integration` runs `go test -tags integration` against a store and a bus started for the
|
||||
run alone (novox/hq ADR 0470). The store is a postgres of the installed package: `initdb` and `pg_ctl` into
|
||||
`<work>/<workspace>/run-<id>/pg`, as the module's account, listening on a unix socket there and on no TCP address, with
|
||||
durability off; the tests get `MESH_TEST_POSTGRES` (the socket directory percent-encoded in the URL, because the
|
||||
controller's tests cut the URL at its last slash). The bus is a nats-server from the Go library at the version this
|
||||
module's `go.mod` names, run by this same binary started as `mesh-build-go bus <dir>` (`bus.go`: JetStream on, a
|
||||
loopback port of its own, its address on the first line, stopped when its input closes); the tests get `MESH_TEST_NATS`.
|
||||
Both run inside the step, are stopped, and the run's directory is removed whatever the tests did.
|
||||
|
||||
Setup (compiling the tests, the store, the bus) is judged within 30 s and the tests' running time (the wall time of
|
||||
`go test`) within 60 s, apart; a level over either says `over_bound` and names the bound, with the slowest tests for the running
|
||||
time. A machine whose installed postgres major is not the recipe's refuses this level, naming both; the unit level
|
||||
still runs. The result names the postgres major and the nats-server version it ran against. The mesh's own store major
|
||||
is not named: no state or grant gives it to this module (ADR 0470 §4 asks for it when it differs), so only the recipe's
|
||||
is said until a source exists.
|
||||
|
||||
## Digest
|
||||
|
||||
`build_go_digest {workspace, level}` answers in its call, for each package of the workspace that has tests at the level,
|
||||
one digest of everything those tests read (ADR 0445 §5): the files, by content and under their path in the tree, of the
|
||||
package and every package its test binary reaches (`go list -test -deps`) that lies in the module's own tree; the version
|
||||
and checksum of every module outside it; the package's own `testdata/` files; and the Go version, the level, the build tags
|
||||
and, at the integration level, the postgres major and the nats-server version. Packages of the standard library are the Go
|
||||
version's. A package with no tests at the level has no digest and is named in `without_tests`. A directory that vendors
|
||||
is listed and tested from its vendor directory (`-mod=vendor`).
|
||||
|
||||
## Build
|
||||
|
||||
`build_go_build {workspace, module, artifact}` builds one Go bundle artifact of a module of the workspace with the build
|
||||
agent's recipe (mesh-controller's `toolchain.go`, the Go entry, copied in `testdata/captured/toolchain-go-entry.txt`, and
|
||||
`builder.go`): `CGO_ENABLED=0`, `GOFLAGS=-trimpath -buildvcs=false` (with `-mod=mod`, or `-mod=vendor` for a directory
|
||||
that vendors), `go build -ldflags "-s -w -X main.builtFor=<system>" -o .mesh-build/<artifact>/<binary> ./<from>` in the
|
||||
module's directory, by the installed Go (`GOTOOLCHAIN=local`) from the module cache the fetch filled and no network
|
||||
(`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with mesh-sdk's `Pack`,
|
||||
so the digest is the build agent's for the same tree. The step ends with the artifact, the binary, the digest, the size and
|
||||
the path of the archive, which stays in the workspace's `artifacts` directory: publishing it is the builder's, in a later
|
||||
release. An artifact that is not a Go bundle fails the step naming it.
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
// The build step (novox/hq ADR 0405 §3 §7, ADR 0453 §7; issue 1487): a Go bundle artifact of a module of a fetched
|
||||
// workspace, built with today's recipe — the build agent's, mesh-controller's internal/builder/toolchain.go (the Go
|
||||
// entry) and builder.go (compile, binaryName, pack) — by the installed Go and no network, packed with mesh-sdk's Pack
|
||||
// so that its digest is the build agent's for the same tree. The archive is left in the workspace; publishing it is
|
||||
// the builder's, in a later release.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The Go recipe of the build agent (toolchain.go, the entry of language go): static, trimmed of its own paths and of
|
||||
// the commit stamp, stripped of symbols and debug info, and told the system it was built for.
|
||||
const (
|
||||
recipeFlags = "-trimpath -buildvcs=false"
|
||||
recipeLinkerFlags = "-s -w"
|
||||
recipeSystemStamp = "main.builtFor"
|
||||
// outDir is where a build leaves an artifact in the module's directory (builder.Out).
|
||||
outDir = ".mesh-build"
|
||||
)
|
||||
|
||||
// BuildResult is what a passed build step says.
|
||||
type BuildResult struct {
|
||||
Artifact string `json:"artifact"`
|
||||
Binary string `json:"binary"`
|
||||
Digest string `json:"digest"`
|
||||
Size int `json:"size"`
|
||||
// Path is the archive, in the workspace's directory of the work directory.
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (m *Module) buildTool() StepTool {
|
||||
return StepTool{
|
||||
Name: "build_go_build",
|
||||
Description: "Build one Go bundle artifact of a module of a fetched workspace with the build agent's recipe, by the " +
|
||||
"installed Go and no network, and pack it. Answers a step id at once; the step ends on its key with the " +
|
||||
"artifact's digest, size and the archive's path.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
|
||||
"module": map[string]any{"type": "string", "description": "the module's directory in the workspace, . for the repository's root"},
|
||||
"artifact": map[string]any{"type": "string", "description": "the artifact's name in the module's manifest"},
|
||||
},
|
||||
"required": []string{"workspace", "module", "artifact"},
|
||||
},
|
||||
Long: true,
|
||||
Run: m.build,
|
||||
}
|
||||
}
|
||||
|
||||
// binaryName is what a compiled bundle's executable is called (builder.go binaryName): what the artifact says, or the
|
||||
// name of the package it is built from, or the artifact's.
|
||||
func binaryName(a catalogue.Artifact) string {
|
||||
if name := catalogue.BinaryOf(a); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||
return name
|
||||
}
|
||||
if from := strings.Trim(a.From, "./"); from != "" {
|
||||
return filepath.Base(from)
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
workspace, _ := args["workspace"].(string)
|
||||
module, _ := args["module"].(string)
|
||||
name, _ := args["artifact"].(string)
|
||||
if !workspaceOK.MatchString(workspace) {
|
||||
return nil, fmt.Errorf("%q is not a workspace id", workspace)
|
||||
}
|
||||
tree := filepath.Join(m.Work, workspace, "tree")
|
||||
dir := filepath.Join(tree, filepath.FromSlash(module))
|
||||
if rel, err := filepath.Rel(tree, dir); err != nil || strings.HasPrefix(rel, "..") {
|
||||
return nil, fmt.Errorf("the module directory %q lies outside the workspace", module)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err)
|
||||
}
|
||||
manifest, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest in %q: %w", module, err)
|
||||
}
|
||||
var art *catalogue.Artifact
|
||||
names := []string{}
|
||||
if manifest.Build != nil {
|
||||
for i := range manifest.Build.Artifacts {
|
||||
names = append(names, manifest.Build.Artifacts[i].Name)
|
||||
if manifest.Build.Artifacts[i].Name == name {
|
||||
art = &manifest.Build.Artifacts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if art == nil {
|
||||
return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", "))
|
||||
}
|
||||
if art.Kind != "bundle" || art.Language != "go" {
|
||||
return nil, fmt.Errorf("the artifact %s of %s is a %s of language %q: this module builds Go bundles", name, manifest.Module, art.Kind, art.Language)
|
||||
}
|
||||
if strings.TrimSpace(art.From) == "" {
|
||||
return nil, fmt.Errorf("the artifact %s of %s says nothing it is built from", name, manifest.Module)
|
||||
}
|
||||
|
||||
// Its own output directory, made empty: a build of the same tree twice is one build.
|
||||
out := filepath.Join(dir, outDir, art.Name)
|
||||
if err := os.RemoveAll(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("making the output directory for %s: %w", art.Name, err)
|
||||
}
|
||||
binary := binaryName(*art)
|
||||
// One -ldflags, composed: a repeated flag is not a merged one (the build agent's issue 161).
|
||||
linker := recipeLinkerFlags
|
||||
if system := strings.TrimSpace(art.System); system != "" {
|
||||
linker += " -X " + recipeSystemStamp + "=" + system
|
||||
}
|
||||
cmd := []string{"-C", dir, "build", "-ldflags", linker, "-o", filepath.Join(out, binary), "./" + strings.Trim(art.From, "./")}
|
||||
if r := m.Run(ctx, m.buildEnv(dir), "go", cmd...); r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go build of %s: %s", art.Name, firstLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
archive, digest, err := buildstep.Pack(out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("packing %s: %w", art.Name, err)
|
||||
}
|
||||
artifacts := filepath.Join(m.Work, workspace, "artifacts")
|
||||
if err := os.MkdirAll(artifacts, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(artifacts, strings.ReplaceAll(module, "/", "_")+"."+art.Name+".tar.gz")
|
||||
if err := os.WriteFile(path, archive, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildResult{Artifact: art.Name, Binary: binary, Digest: digest, Size: len(archive), Path: path}, nil
|
||||
}
|
||||
|
||||
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed toolchain
|
||||
// alone, the modules the fetch put in the module's cache and no way to fetch another (GOPROXY=off), so a build never
|
||||
// reaches the network. A directory that vendors is built from its vendor directory.
|
||||
func (m *Module) buildEnv(dir string) []string {
|
||||
mod := "-mod=mod"
|
||||
if _, err := os.Stat(filepath.Join(dir, "vendor", "modules.txt")); err == nil {
|
||||
mod = "-mod=vendor"
|
||||
}
|
||||
return []string{
|
||||
"CGO_ENABLED=0",
|
||||
"GOFLAGS=" + recipeFlags + " " + mod,
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
"GOPROXY=off",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// Issue 1487: the build step, with the compiler faked at the process boundary: it writes the file `go build -o` names.
|
||||
|
||||
func manifestWith(artifact string) string {
|
||||
return `{"module":"demo","version":"1","build":{"artifacts":[` + artifact + `]}}`
|
||||
}
|
||||
|
||||
const goBundle = `{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/demo-tools","binary":"demo-bin","loads":["demo-bin"]}`
|
||||
|
||||
// buildModule is a module with a workspace holding a module "modules/demo" with this manifest; the compiler writes
|
||||
// content to the file go build is told.
|
||||
func buildModule(t *testing.T, manifest, content string) (*Module, *keptStates, *[]cmdRecord) {
|
||||
t.Helper()
|
||||
calls := &[]cmdRecord{}
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.1")
|
||||
m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir()
|
||||
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
*calls = append(*calls, cmdRecord{env: env, line: name + " " + strings.Join(args, " ")})
|
||||
for i, a := range args {
|
||||
if a == "-o" {
|
||||
if err := os.WriteFile(args[i+1], []byte(content), 0o755); err != nil {
|
||||
return forgekey.Ran{Status: 1, Stderr: err.Error()}
|
||||
}
|
||||
}
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
return m, states, calls
|
||||
}
|
||||
|
||||
func runBuild(t *testing.T, m *Module, states *keptStates, artifact string) (BuildResult, delivery.Step) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == "build_go_build" {
|
||||
if _, err := tool.Run(map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": artifact}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res BuildResult
|
||||
if st.State == delivery.StepPassed {
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return res, st
|
||||
}
|
||||
|
||||
// goEntry reads what the build agent's recipe says out of lines 155-191 of mesh-control's internal/builder/toolchain.go
|
||||
// (testdata/captured/toolchain-go-entry.txt, copied from origin/main 073ccc29): the environment words and flags of the
|
||||
// compile, the linker flags and the system stamp.
|
||||
func goEntry(t *testing.T) (compile []string, linker []string, stamp string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "captured", "toolchain-go-entry.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
find := func(pattern string) string {
|
||||
m := regexp.MustCompile(pattern).FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
t.Fatalf("the copied recipe has nothing like %s", pattern)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
for _, q := range regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(find(`Compile: \[\]string\{([^}]*)\}`), -1) {
|
||||
compile = append(compile, q[1])
|
||||
}
|
||||
for _, q := range regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(find(`LinkerFlags: \[\]string\{([^}]*)\}`), -1) {
|
||||
linker = append(linker, q[1])
|
||||
}
|
||||
return compile, linker, find(`SystemStamp: "([^"]+)"`)
|
||||
}
|
||||
|
||||
// 1487/AC1: the build command carries today's flags and environment: the copied recipe says env CGO_ENABLED=0
|
||||
// GOFLAGS=-trimpath -buildvcs=false, go build, -ldflags -s -w -X main.builtFor=<system>, -o <out>/<binary>, the package.
|
||||
func TestTheBuildCommandCarriesTodaysFlagsAndEnvironment(t *testing.T) {
|
||||
m, states, calls := buildModule(t, manifestWith(goBundle), "binary")
|
||||
res, st := runBuild(t, m, states, "tools")
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
compile, linker, stamp := goEntry(t)
|
||||
// compile is: env CGO_ENABLED=0 "GOFLAGS=-trimpath -buildvcs=false" go build
|
||||
if len(compile) != 5 || compile[0] != "env" || compile[3] != "go" || compile[4] != "build" {
|
||||
t.Fatalf("the copied recipe's compile is %v", compile)
|
||||
}
|
||||
var c cmdRecord
|
||||
for _, r := range *calls {
|
||||
if strings.HasPrefix(r.line, "go ") {
|
||||
c = r
|
||||
}
|
||||
}
|
||||
env := strings.Join(c.env, "\n")
|
||||
for _, want := range []string{compile[1], "GOFLAGS=" + strings.TrimPrefix(compile[2], "GOFLAGS=") + " -mod=mod", "GOTOOLCHAIN=local", "GOPROXY=off",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"), "GOCACHE=" + filepath.Join(m.Cache, "gobuild")} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("the environment lacks %q:\n%s", want, env)
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
|
||||
out := filepath.Join(dir, ".mesh-build", "tools")
|
||||
want := "go -C " + dir + " build -ldflags " + strings.Join(linker, " ") + " -X " + stamp + "=arch -o " + filepath.Join(out, "demo-bin") + " ./cmd/demo-tools"
|
||||
if c.line != want {
|
||||
t.Errorf("command = %q\nwant %q", c.line, want)
|
||||
}
|
||||
if res.Artifact != "tools" || res.Binary != "demo-bin" || !strings.HasPrefix(res.Digest, "sha256:") || res.Size == 0 {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// The archive is the file the digest names, holding one executable of the name binaryName says; two builds of one tree
|
||||
// are one digest.
|
||||
func TestTheArchiveHoldsOneExecutableAndTwoBuildsAreOneDigest(t *testing.T) {
|
||||
m, states, _ := buildModule(t, manifestWith(goBundle), "the program")
|
||||
first, _ := runBuild(t, m, states, "tools")
|
||||
second, _ := runBuild(t, m, states, "tools")
|
||||
if first.Digest != second.Digest || first.Path != second.Path {
|
||||
t.Errorf("two builds: %+v and %+v", first, second)
|
||||
}
|
||||
raw, err := os.ReadFile(first.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
if first.Digest != "sha256:"+hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("the digest %s is not the archive's", first.Digest)
|
||||
}
|
||||
zr, err := gzip.NewReader(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(zr)
|
||||
h, err := tr.Next()
|
||||
if err != nil || h.Name != "demo-bin" || h.Mode != 0o755 {
|
||||
t.Fatalf("first entry = %+v, %v", h, err)
|
||||
}
|
||||
if body, _ := io.ReadAll(tr); string(body) != "the program" {
|
||||
t.Errorf("the executable holds %q", body)
|
||||
}
|
||||
if _, err := tr.Next(); err != io.EOF {
|
||||
t.Errorf("the archive holds more than one file")
|
||||
}
|
||||
}
|
||||
|
||||
// 1487/AC3: an artifact of another language, or another kind, fails the step naming it.
|
||||
func TestAnArtifactOfAnotherLanguageFailsTheStepNamingIt(t *testing.T) {
|
||||
for artifact, want := range map[string]string{
|
||||
`{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"]}`: `language "typescript"`,
|
||||
`{"name":"tools","kind":"archive","from":"files"}`: "archive",
|
||||
} {
|
||||
m, states, calls := buildModule(t, manifestWith(artifact), "x")
|
||||
_, st := runBuild(t, m, states, "tools")
|
||||
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, want) || len(*calls) != 0 {
|
||||
t.Errorf("%s: the step ended %s: %q after %d commands", artifact, st.State, st.Reason, len(*calls))
|
||||
}
|
||||
}
|
||||
m, states, _ := buildModule(t, manifestWith(goBundle), "x")
|
||||
if _, st := runBuild(t, m, states, "nope"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, `"nope"`) || !strings.Contains(st.Reason, "tools") {
|
||||
t.Errorf("an unknown artifact: %s %q", st.State, st.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// The executable is named as the build agent names it: the artifact's binary, else the package's directory, else the
|
||||
// artifact.
|
||||
func TestTheBinaryIsNamedAsTheBuildAgentNamesIt(t *testing.T) {
|
||||
for artifact, want := range map[string]string{
|
||||
goBundle: "demo-bin",
|
||||
`{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/demo-tools","loads":["demo-tools"]}`: "demo-tools",
|
||||
`{"name":"tools","kind":"bundle","language":"go","system":"arch","from":".","loads":["tools"]}`: "tools",
|
||||
} {
|
||||
m, states, _ := buildModule(t, manifestWith(artifact), "x")
|
||||
if res, st := runBuild(t, m, states, "tools"); res.Binary != want {
|
||||
t.Errorf("%s: binary %q, want %q (%s %s)", artifact, res.Binary, want, st.State, st.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleDirectoryOutsideTheWorkspaceIsRefused(t *testing.T) {
|
||||
m, states, calls := buildModule(t, manifestWith(goBundle), "x")
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == "build_go_build" {
|
||||
tool.Run(map[string]any{"workspace": "ws1", "module": "../../..", "artifact": "tools"})
|
||||
}
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(*calls) != 0 {
|
||||
t.Errorf("the step ended %s after %d commands", st.State, len(*calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
// The integration level's throwaway bus (novox/hq ADR 0470 §2; issue 1486): the module's own program runs a
|
||||
// nats-server from the Go library, at the one version this module's go.mod names, with JetStream on, its store in the
|
||||
// run's directory and a loopback port of its own. The program is this binary started again as `mesh-build-go bus
|
||||
// <dir>`, so the bus is built from the same go.mod as the version the recipe names (ADR 0470 §4) and no second program
|
||||
// has to be built and shipped; the tools bundle never serves a bus, and a bus never serves tools. It prints its
|
||||
// address on one line and stops when its standard input closes.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
// busStartBound is how long a bus is given to answer its address.
|
||||
const busStartBound = 30 * time.Second
|
||||
|
||||
// busArg is the first argument that makes this binary a bus.
|
||||
const busArg = "bus"
|
||||
|
||||
// runBus is the bus program: args are the directory of its store. It returns the exit status.
|
||||
func runBus(args []string) int {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: mesh-build-go bus <store directory>")
|
||||
return 2
|
||||
}
|
||||
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: args[0], NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(busStartBound) {
|
||||
fmt.Fprintln(os.Stderr, "the bus was not ready within", busStartBound)
|
||||
s.Shutdown()
|
||||
return 1
|
||||
}
|
||||
fmt.Println(s.ClientURL())
|
||||
done := make(chan struct{})
|
||||
go func() { _, _ = io.Copy(io.Discard, os.Stdin); close(done) }()
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
||||
select {
|
||||
case <-done:
|
||||
case <-sig:
|
||||
}
|
||||
s.Shutdown()
|
||||
s.WaitForShutdown()
|
||||
return 0
|
||||
}
|
||||
|
||||
// startBus starts the bus of a run as a process of this binary under the step's environment, and answers its address.
|
||||
func (m *Module) startBus(ctx context.Context, dir string) (string, func(), error) {
|
||||
if m.StartBus != nil {
|
||||
return m.StartBus(ctx, dir)
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cmd := exec.Command(exe, busArg, dir)
|
||||
cmd.Env = baseEnv(m.Home)
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var errb strings.Builder
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
stop := func() {
|
||||
_ = in.Close()
|
||||
exited := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(exited) }()
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-exited
|
||||
}
|
||||
}
|
||||
line := make(chan string, 1)
|
||||
go func() {
|
||||
l, _ := bufio.NewReader(out).ReadString('\n')
|
||||
line <- strings.TrimSpace(l)
|
||||
}()
|
||||
select {
|
||||
case url := <-line:
|
||||
if !strings.HasPrefix(url, "nats://") {
|
||||
stop()
|
||||
return "", nil, fmt.Errorf("the bus did not answer an address: %s", firstLine(errb.String()+" "+url))
|
||||
}
|
||||
return url, stop, nil
|
||||
case <-time.After(busStartBound + 5*time.Second):
|
||||
stop()
|
||||
return "", nil, fmt.Errorf("the bus did not answer an address within %s", busStartBound)
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return "", nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
// The digest step (novox/hq ADR 0445 §5, ADR 0444; issue 1291): for each package of a workspace that has tests at a
|
||||
// level, one digest of everything those tests read. The checker keys the verdict it keeps by it, so a package whose
|
||||
// digest is unchanged is not run again. The tool answers in its call (it is short, ADR 0469 §2).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
|
||||
const DigestVersion = "1"
|
||||
|
||||
// goPackage is the part of `go list -json` a digest reads.
|
||||
type goPackage struct {
|
||||
ImportPath string
|
||||
Dir string
|
||||
ForTest string
|
||||
Standard bool
|
||||
DepOnly bool
|
||||
Deps []string
|
||||
Module *goModule
|
||||
// The files a package is built and tested from.
|
||||
GoFiles, CgoFiles, CFiles, CXXFiles, MFiles, HFiles, FFiles, SFiles, SwigFiles, SwigCXXFiles, SysoFiles []string
|
||||
TestGoFiles, XTestGoFiles []string
|
||||
EmbedFiles, TestEmbedFiles, XTestEmbedFiles []string
|
||||
}
|
||||
|
||||
type goModule struct {
|
||||
Path, Version, Sum string
|
||||
Main bool
|
||||
Replace *goModule
|
||||
Dir string
|
||||
GoVersion string
|
||||
}
|
||||
|
||||
func (p *goPackage) files() []string {
|
||||
var all []string
|
||||
for _, l := range [][]string{p.GoFiles, p.CgoFiles, p.CFiles, p.CXXFiles, p.MFiles, p.HFiles, p.FFiles, p.SFiles, p.SwigFiles,
|
||||
p.SwigCXXFiles, p.SysoFiles, p.TestGoFiles, p.XTestGoFiles, p.EmbedFiles, p.TestEmbedFiles, p.XTestEmbedFiles} {
|
||||
all = append(all, l...)
|
||||
}
|
||||
sort.Strings(all)
|
||||
return slicesCompact(all)
|
||||
}
|
||||
|
||||
func slicesCompact(s []string) []string {
|
||||
out := s[:0:0]
|
||||
for i, v := range s {
|
||||
if i == 0 || v != s[i-1] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasTests says whether the package has test files at the level go list was asked for.
|
||||
func (p *goPackage) hasTests() bool { return len(p.TestGoFiles)+len(p.XTestGoFiles) > 0 }
|
||||
|
||||
// DigestResult is what a digest step answers: one digest per package that has tests at the level, and the packages
|
||||
// that have none.
|
||||
type DigestResult struct {
|
||||
Level string `json:"level"`
|
||||
Packages map[string]string `json:"packages"`
|
||||
WithoutTests []string `json:"without_tests,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Module) digestTool() StepTool {
|
||||
return StepTool{
|
||||
Name: "build_go_digest",
|
||||
Description: "One digest per package of a fetched workspace, for one level, of everything that package's tests read: " +
|
||||
"the files of each package the tests reach in the module's own tree, the version and checksum of each module " +
|
||||
"outside it, the package's test data and embedded files, the Go version, the build tags and, at the " +
|
||||
"integration level, the postgres major and nats-server versions. Answers in the call.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
|
||||
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
|
||||
},
|
||||
"required": []string{"workspace", "level"},
|
||||
},
|
||||
Run: m.digest,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
|
||||
workspace, _ := args["workspace"].(string)
|
||||
level, _ := args["level"].(string)
|
||||
if !workspaceOK.MatchString(workspace) {
|
||||
return nil, fmt.Errorf("%q is not a workspace id", workspace)
|
||||
}
|
||||
tree := filepath.Join(m.Work, workspace, "tree")
|
||||
if _, err := os.Stat(tree); err != nil {
|
||||
return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
|
||||
}
|
||||
var tags []string
|
||||
header := []string{"digest " + DigestVersion, "level " + level, "go " + m.Recipe.Go}
|
||||
switch level {
|
||||
case LevelUnit:
|
||||
case LevelIntegration:
|
||||
if _, err := m.checkPostgres(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = []string{"integration"}
|
||||
header = append(header, "postgres "+strconv.Itoa(m.Recipe.PostgresMajor), "nats-server "+m.Recipe.NatsServer)
|
||||
default:
|
||||
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
|
||||
}
|
||||
header = append(header, "tags "+strings.Join(tags, ","))
|
||||
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := DigestResult{Level: level, Packages: map[string]string{}}
|
||||
for _, d := range dirs {
|
||||
pkgs, err := m.listPackages(ctx, tree, d, tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := digestModule(&res, pkgs, tree, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Strings(res.WithoutTests)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// listPackages is `go list -test -deps -json` of the directory's packages, by import path.
|
||||
func (m *Module) listPackages(ctx context.Context, tree, dir string, tags []string) (map[string]*goPackage, error) {
|
||||
abs := filepath.Join(tree, dir)
|
||||
args := []string{"-C", abs, "list", "-test", "-deps", "-json"}
|
||||
if len(tags) > 0 {
|
||||
args = append(args, "-tags", strings.Join(tags, ","))
|
||||
}
|
||||
args = append(args, "./...")
|
||||
r := m.Run(ctx, m.goEnvFor(abs), "go", args...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go list in %s: %s", dir, firstLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
return parsePackages(r.Stdout)
|
||||
}
|
||||
|
||||
// parsePackages reads `go list -json`'s stream of objects.
|
||||
func parsePackages(out string) (map[string]*goPackage, error) {
|
||||
pkgs := map[string]*goPackage{}
|
||||
dec := json.NewDecoder(strings.NewReader(out))
|
||||
for {
|
||||
var p goPackage
|
||||
if err := dec.Decode(&p); err == io.EOF {
|
||||
return pkgs, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("go list answered something that is not its JSON: %w", err)
|
||||
}
|
||||
pkgs[p.ImportPath] = &p
|
||||
}
|
||||
}
|
||||
|
||||
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
|
||||
// module that is listed, not only a dependency, and not a variant of another.
|
||||
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header []string) error {
|
||||
roots := []string{}
|
||||
for path, p := range pkgs {
|
||||
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
|
||||
roots = append(roots, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
for _, path := range roots {
|
||||
root := pkgs[path]
|
||||
if !root.hasTests() {
|
||||
res.WithoutTests = append(res.WithoutTests, path)
|
||||
continue
|
||||
}
|
||||
testMain := pkgs[path+".test"]
|
||||
if testMain == nil {
|
||||
return fmt.Errorf("go list gave no test binary for %s", path)
|
||||
}
|
||||
d, err := packageDigest(root, testMain, pkgs, tree, header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
res.Packages[path] = d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its
|
||||
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
|
||||
// every module outside it, and the package's own test data. A package of the standard library is the Go version's.
|
||||
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header []string) (string, error) {
|
||||
reached := map[string]*goPackage{root.ImportPath: root}
|
||||
for _, dep := range testMain.Deps {
|
||||
if p := pkgs[dep]; p != nil {
|
||||
reached[dep] = p
|
||||
}
|
||||
}
|
||||
lines := []string{}
|
||||
for _, p := range reached {
|
||||
if p.Standard || p.Module == nil {
|
||||
continue
|
||||
}
|
||||
mod := p.Module
|
||||
if mod.Replace != nil {
|
||||
mod = mod.Replace
|
||||
}
|
||||
if !mod.Main && mod.Version != "" {
|
||||
lines = append(lines, "module "+mod.Path+"@"+mod.Version+" "+mod.Sum)
|
||||
continue
|
||||
}
|
||||
for _, f := range p.files() {
|
||||
l, err := fileLine(tree, p, f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
data := filepath.Join(root.Dir, "testdata")
|
||||
if _, err := os.Stat(data); err == nil {
|
||||
err := filepath.WalkDir(data, func(path string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return err
|
||||
}
|
||||
l, err := contentLine(tree, root, path)
|
||||
lines = append(lines, l)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range append(append([]string{}, header...), lines...) {
|
||||
h.Write([]byte(l + "\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func fileLine(tree string, p *goPackage, name string) (string, error) {
|
||||
return contentLine(tree, p, filepath.Join(p.Dir, name))
|
||||
}
|
||||
|
||||
// contentLine is "file <path> <sha256>": the path is the file's in the tree (the import path and the name when it lies
|
||||
// outside, as a local replacement does), so a digest does not depend on where the workspace is.
|
||||
func contentLine(tree string, p *goPackage, path string) (string, error) {
|
||||
label := p.ImportPath + "/" + filepath.Base(path)
|
||||
if rel, err := filepath.Rel(tree, path); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
label = filepath.ToSlash(rel)
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
to, _ := os.Readlink(path)
|
||||
return "link " + label + " " + to, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "file " + label + " " + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// Issue 1291: the digests of a fixture module (testdata/repos/digest): a's tests import b and read a/testdata, c's
|
||||
// tests read an embedded file, d is reached by no test. They run the real `go list` (it loads packages, it builds
|
||||
// none), through the step runner; only the listing of the tree's go.mod files and postgres's version are answered.
|
||||
|
||||
// digestModule is a module over a copy of the fixture, in a workspace of its own.
|
||||
func digestFixture(t *testing.T) (*Module, string) {
|
||||
t.Helper()
|
||||
work := t.TempDir()
|
||||
tree := filepath.Join(work, "ws1", "tree")
|
||||
if err := os.MkdirAll(tree, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("cp", "-r", filepath.Join("testdata", "repos", "digest")+"/.", tree).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
m := moduleWith(newStates(), "go1.27.1")
|
||||
m.Home, m.Work, m.Cache = t.TempDir(), work, t.TempDir()
|
||||
real := StepRunner(m.Home)
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
switch name {
|
||||
case "git":
|
||||
return forgekey.Ran{Stdout: "go.mod\n"}
|
||||
case "postgres":
|
||||
return forgekey.Ran{Stdout: "postgres (PostgreSQL) " + strconv.Itoa(m.Recipe.PostgresMajor) + ".1\n"}
|
||||
}
|
||||
return real(ctx, env, name, args...)
|
||||
}
|
||||
return m, tree
|
||||
}
|
||||
|
||||
func digests(t *testing.T, m *Module, level string) DigestResult {
|
||||
t.Helper()
|
||||
out, err := m.digest(context.Background(), map[string]any{"workspace": "ws1", "level": level})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.(DigestResult)
|
||||
}
|
||||
|
||||
func edit(t *testing.T, tree, name, text string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(tree, name), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1291/AC1: a file of b changes: a's digest changes (its tests import b), c's does not.
|
||||
func TestADependencysFileChangesTheDigestOfThePackageWhoseTestsImportIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, tree := digestFixture(t)
|
||||
before := digests(t, m, "unit")
|
||||
for _, p := range []string{"fixture/digest/a", "fixture/digest/c"} {
|
||||
if len(before.Packages[p]) != 64 {
|
||||
t.Fatalf("no digest for %s: %+v", p, before)
|
||||
}
|
||||
}
|
||||
edit(t, tree, "b/b.go", "package b\n\n// B is reached by a's tests.\nfunc B() int { return 2 + 0 }\n")
|
||||
after := digests(t, m, "unit")
|
||||
if after.Packages["fixture/digest/a"] == before.Packages["fixture/digest/a"] {
|
||||
t.Errorf("a's digest did not change when b did")
|
||||
}
|
||||
if after.Packages["fixture/digest/c"] != before.Packages["fixture/digest/c"] {
|
||||
t.Errorf("c's digest changed when b did")
|
||||
}
|
||||
if again := digests(t, m, "unit"); again.Packages["fixture/digest/a"] != after.Packages["fixture/digest/a"] {
|
||||
t.Errorf("one tree has two digests")
|
||||
}
|
||||
}
|
||||
|
||||
// 1291/AC2: a file under a's testdata/ changes: a's digest changes. An embedded file does the same for c.
|
||||
func TestATestdataFileAndAnEmbeddedFileChangeTheDigest(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, tree := digestFixture(t)
|
||||
before := digests(t, m, "unit")
|
||||
edit(t, tree, "a/testdata/x.txt", "y\n")
|
||||
after := digests(t, m, "unit")
|
||||
if after.Packages["fixture/digest/a"] == before.Packages["fixture/digest/a"] {
|
||||
t.Errorf("a's digest did not change with its testdata")
|
||||
}
|
||||
if after.Packages["fixture/digest/c"] != before.Packages["fixture/digest/c"] {
|
||||
t.Errorf("c's digest changed with a's testdata")
|
||||
}
|
||||
edit(t, tree, "c/note.txt", "another note\n")
|
||||
if again := digests(t, m, "unit"); again.Packages["fixture/digest/c"] == after.Packages["fixture/digest/c"] {
|
||||
t.Errorf("c's digest did not change with its embedded file")
|
||||
}
|
||||
}
|
||||
|
||||
// 1291/AC3: one tree at the unit and the integration level: the digests differ; at the integration level a change of the
|
||||
// recipe's postgres major (or of the nats-server version) changes it, at the unit level it does not.
|
||||
func TestTheLevelTheStoreAndTheBusAreInTheDigest(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, _ := digestFixture(t)
|
||||
unit, integration := digests(t, m, "unit"), digests(t, m, "integration")
|
||||
a := "fixture/digest/a"
|
||||
if unit.Packages[a] == integration.Packages[a] {
|
||||
t.Errorf("the unit and the integration digest of a are one")
|
||||
}
|
||||
m.Recipe.PostgresMajor++
|
||||
if got := digests(t, m, "integration"); got.Packages[a] == integration.Packages[a] {
|
||||
t.Errorf("a new postgres major left the integration digest as it was")
|
||||
}
|
||||
if got := digests(t, m, "unit"); got.Packages[a] != unit.Packages[a] {
|
||||
t.Errorf("a new postgres major moved the unit digest")
|
||||
}
|
||||
m.Recipe.PostgresMajor--
|
||||
m.Recipe.NatsServer = "9.9.9"
|
||||
if got := digests(t, m, "integration"); got.Packages[a] == integration.Packages[a] {
|
||||
t.Errorf("a new nats-server version left the integration digest as it was")
|
||||
}
|
||||
m.Recipe.NatsServer = "2.11.17"
|
||||
m.Recipe.Go = "go1.27.2"
|
||||
if got := digests(t, m, "unit"); got.Packages[a] == unit.Packages[a] {
|
||||
t.Errorf("a new Go version left the unit digest as it was")
|
||||
}
|
||||
}
|
||||
|
||||
// 1291/AC4: a change in a package no test of a reaches leaves a's digest as it was; d, with no tests, has none.
|
||||
func TestAPackageNoTestReachesChangesNoDigest(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, tree := digestFixture(t)
|
||||
before := digests(t, m, "unit")
|
||||
edit(t, tree, "d/d.go", "package d\n\n// D is reached by no test.\nfunc D() int { return 5 }\n")
|
||||
after := digests(t, m, "unit")
|
||||
for p, d := range before.Packages {
|
||||
if after.Packages[p] != d {
|
||||
t.Errorf("the digest of %s changed with a package no test of it reaches", p)
|
||||
}
|
||||
}
|
||||
if _, has := after.Packages["fixture/digest/d"]; has || strings.Join(after.WithoutTests, " ") != "fixture/digest/b fixture/digest/d" {
|
||||
t.Errorf("without tests = %v, packages = %v", after.WithoutTests, after.Packages)
|
||||
}
|
||||
}
|
||||
|
||||
// The digest does not depend on where the workspace lies.
|
||||
func TestTheDigestIsTheSameInAnotherWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
m1, _ := digestFixture(t)
|
||||
m2, _ := digestFixture(t)
|
||||
if d1, d2 := digests(t, m1, "unit"), digests(t, m2, "unit"); d1.Packages["fixture/digest/a"] != d2.Packages["fixture/digest/a"] {
|
||||
t.Errorf("one tree in two places has two digests")
|
||||
}
|
||||
}
|
||||
|
||||
// A module outside the tree counts by its version and checksum, not by its files: the package of mesh-sdk, as go list
|
||||
// gave it for this module's own dependency (testdata/captured/golist-external.json, which README describes).
|
||||
func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "captured", "golist-external.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkgs, err := parsePackages(string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ext := pkgs["git.novox.be/novox/mesh-sdk/go/buildstep"]
|
||||
if ext == nil || ext.Module == nil || ext.Module.Sum == "" || ext.Module.Version == "" || ext.Module.Main {
|
||||
t.Fatalf("the capture holds no external package with a version and a checksum: %+v", ext)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "r.go"), []byte("package r\n"), 0o644)
|
||||
root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}}
|
||||
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
|
||||
pkgs[root.ImportPath] = root
|
||||
digest := func() string {
|
||||
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
d1 := digest()
|
||||
ext.Module.Sum = "h1:changed="
|
||||
if d2 := digest(); d1 == d2 {
|
||||
t.Errorf("a new checksum of a module outside the tree left the digest as it was")
|
||||
}
|
||||
ext.Module.Sum, ext.Module.Version = "h1:changed=", "v9.9.9"
|
||||
if d3 := digest(); d3 == d1 {
|
||||
t.Errorf("a new version left the digest as it was")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issues 1483 and 1488 (for the indexer, issue 1444): the guard emits toolchain.reported after each write of the
|
||||
// toolchains key, with the node and the language.
|
||||
func TestTheGuardEmitsToolchainReportedAfterEachWrite(t *testing.T) {
|
||||
m := moduleWith(newStates(), "go1.27.1")
|
||||
type event struct {
|
||||
typ string
|
||||
body map[string]string
|
||||
}
|
||||
var got []event
|
||||
m.Emit = func(typ string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
var b map[string]string
|
||||
json.Unmarshal(raw, &b)
|
||||
got = append(got, event{typ, b})
|
||||
return nil
|
||||
}
|
||||
if err := m.Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].typ != "toolchain.reported" || got[0].body["node"] != "ace" || got[0].body["language"] != "go" || len(got[0].body) != 2 {
|
||||
t.Errorf("events = %+v, want one toolchain.reported for ace, go", got)
|
||||
}
|
||||
m.Check(context.Background())
|
||||
if len(got) != 2 {
|
||||
t.Errorf("a second guard pass emitted %d events in all, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// The event is carried by the module's stream, not named in emits (ADR 0363: one place); the validator checks that.
|
||||
func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) {
|
||||
t.Errorf("streams = %+v (%v)", m.Streams, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
// The fetch step (novox/hq ADR 0405 §5 §6, ADR 0466 §4 §5, ADR 0469 §2; issue 1485): a commit of a repository of
|
||||
// the mesh's forge, fetched over SSH as the module's own account into a workspace of its own, and the Go modules every
|
||||
// go.mod in it names, downloaded into the module's cache and verified against go.sum.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
|
||||
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
||||
const ForgeAlias = "mesh-forge"
|
||||
|
||||
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
|
||||
// fetches and every round it checks its toolchain. A workspace lies in the module's work directory, never in its
|
||||
// home (ADR 0466 §1).
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
var (
|
||||
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
)
|
||||
|
||||
// FetchResult is what a passed fetch step says: the workspace it made, the commit in it and the directories whose
|
||||
// Go modules it downloaded.
|
||||
type FetchResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Commit string `json:"commit"`
|
||||
Modules []string `json:"modules"`
|
||||
}
|
||||
|
||||
// fetchTool is the fetch step tool.
|
||||
func (m *Module) fetchTool() StepTool {
|
||||
return StepTool{
|
||||
Name: "build_go_fetch",
|
||||
Description: "Fetch a commit of a repository of the mesh's forge over SSH, as this module's own account, into a " +
|
||||
"workspace of its own, and download and verify the Go modules every go.mod in it names. Answers a step id at " +
|
||||
"once; the step ends on its key in the steps state with the workspace's id.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
||||
},
|
||||
"required": []string{"repository", "commit"},
|
||||
},
|
||||
Long: true,
|
||||
Run: m.fetch,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
m.CleanWorkspaces(m.now())
|
||||
id := buildstep.NewID()
|
||||
root := filepath.Join(m.Work, id)
|
||||
tree := filepath.Join(root, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(tree, d, "vendor", "modules.txt")); err == nil {
|
||||
continue // a module that vendors resolves nothing
|
||||
}
|
||||
for _, verb := range []string{"download", "verify"} {
|
||||
r := m.Run(ctx, m.goEnv(), "go", "-C", filepath.Join(tree, d), "mod", verb)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("go mod %s in %s: %s", verb, d, firstLine(r.Err+"\n"+r.Stdout+"\n"+r.Stderr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return FetchResult{Workspace: id, Commit: commit, Modules: dirs}, nil
|
||||
}
|
||||
|
||||
// goModDirs are the directories of the tree that hold a go.mod, relative to it, in order.
|
||||
func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
|
||||
r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
dirs = append(dirs, filepath.ToSlash(filepath.Dir(l)))
|
||||
}
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// goEnv is what a go command of a step is given beyond the base environment: the installed toolchain and no other,
|
||||
// modules resolved by their go.mod and go.sum, the module's own caches, the mesh's own modules fetched directly (by
|
||||
// git, through the account's rewrite of the forge's address to ssh), and nothing that asks a question. For a directory
|
||||
// that vendors, see goEnvFor.
|
||||
func (m *Module) goEnv() []string {
|
||||
return []string{
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOFLAGS=-mod=mod",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
"GOPRIVATE=git.novox.be",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_SSH_COMMAND=ssh -o BatchMode=yes",
|
||||
}
|
||||
}
|
||||
|
||||
// goEnvFor is goEnv for a command run in dir: a directory that vendors (vendor/modules.txt) is read from its vendor
|
||||
// directory, as fetch downloaded nothing for it, and every other by its go.mod and go.sum.
|
||||
func (m *Module) goEnvFor(dir string) []string {
|
||||
env := m.goEnv()
|
||||
if _, err := os.Stat(filepath.Join(dir, "vendor", "modules.txt")); err == nil {
|
||||
env = append(env, "GOFLAGS=-mod=vendor") // the last value of a name is the one used
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
|
||||
func (m *Module) CleanWorkspaces(now time.Time) {
|
||||
entries, err := os.ReadDir(m.Work)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
|
||||
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clock is the time now: Now when a test set it, else the wall clock.
|
||||
func (m *Module) clock() time.Time {
|
||||
if m.Now != nil {
|
||||
return m.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
func (m *Module) now() time.Time { return m.clock() }
|
||||
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const commitSHA = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// cmdRecord is one command a step ran.
|
||||
type cmdRecord struct {
|
||||
env []string
|
||||
line string
|
||||
}
|
||||
|
||||
// fakeStep answers the commands of a fetch: git succeeds, rev-parse answers the commit, ls-files answers the
|
||||
// go.mod files, and a go command answers by rule.
|
||||
type fakeStep struct {
|
||||
mu sync.Mutex
|
||||
calls []cmdRecord
|
||||
gomods string
|
||||
gate chan struct{} // when set, the first git command waits for it
|
||||
goRule func(line string) forgekey.Ran
|
||||
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
|
||||
}
|
||||
|
||||
func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, cmdRecord{env: env, line: line})
|
||||
f.mu.Unlock()
|
||||
if f.gate != nil && strings.Contains(line, " init ") {
|
||||
<-f.gate
|
||||
}
|
||||
switch {
|
||||
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
|
||||
return forgekey.Ran{Stdout: commitSHA + "\n"}
|
||||
case name == "git" && strings.Contains(line, " ls-files "):
|
||||
return forgekey.Ran{Stdout: f.gomods}
|
||||
case name == "git" && strings.Contains(line, " checkout "):
|
||||
for i, a := range args {
|
||||
if a == "-C" {
|
||||
for _, v := range f.vendors {
|
||||
os.MkdirAll(filepath.Join(args[i+1], v, "vendor"), 0o755)
|
||||
os.WriteFile(filepath.Join(args[i+1], v, "vendor", "modules.txt"), nil, 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
case name == "go" && f.goRule != nil:
|
||||
return f.goRule(line)
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
|
||||
func (f *fakeStep) lines() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []string{}
|
||||
for _, c := range f.calls {
|
||||
out = append(out, c.line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchModule(t *testing.T, f *fakeStep) (*Module, *keptStates) {
|
||||
t.Helper()
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.1")
|
||||
m.Run, m.Home, m.Work, m.Cache = f.run, t.TempDir(), t.TempDir(), t.TempDir()
|
||||
return m, states
|
||||
}
|
||||
|
||||
func fetchTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == "build_go_fetch" {
|
||||
return tool.Run
|
||||
}
|
||||
}
|
||||
t.Fatal("no tool build_go_fetch")
|
||||
return nil
|
||||
}
|
||||
|
||||
var fetchArgs = map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA}
|
||||
|
||||
// 1485/AC1: git is asked through FetchCommit with the ssh remote, and go mod download and verify run in each go.mod
|
||||
// directory (not in one that vendors) with GOTOOLCHAIN=local, GOFLAGS=-mod=mod and the module's cache paths.
|
||||
func TestFetchAsksGitForTheSshRemoteAndDownloadsEachModule(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\nsub/dir/go.mod\nvend/go.mod\n", vendors: []string{"vend"}}
|
||||
m, states := fetchModule(t, f)
|
||||
if _, err := fetchTool(t, m)(fetchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, want := range []string{
|
||||
"remote add origin ssh://mesh-forge/novox/mesh-sdk.git",
|
||||
"fetch --no-tags --depth 1 origin " + commitSHA,
|
||||
"checkout -q --detach " + commitSHA,
|
||||
} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
downloads, verifies := 0, 0
|
||||
for _, c := range f.calls {
|
||||
switch {
|
||||
case strings.HasPrefix(c.line, "go -C ") && strings.HasSuffix(c.line, " mod download"):
|
||||
downloads++
|
||||
case strings.HasPrefix(c.line, "go -C ") && strings.HasSuffix(c.line, " mod verify"):
|
||||
verifies++
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if strings.Contains(c.line, "vend") {
|
||||
t.Errorf("a module that vendors was downloaded: %s", c.line)
|
||||
}
|
||||
env := strings.Join(c.env, " ")
|
||||
for _, want := range []string{"GOTOOLCHAIN=local", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod"), "GOCACHE=" + filepath.Join(m.Cache, "gobuild"), "GOPRIVATE=git.novox.be"} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("%s: environment lacks %s: %s", c.line, want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
if downloads != 2 || verifies != 2 {
|
||||
t.Errorf("%d downloads and %d verifies, want 2 each (go.mod and sub/dir/go.mod):\n%s", downloads, verifies, lines)
|
||||
}
|
||||
var res FetchResult
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Commit != commitSHA || strings.Join(res.Modules, ",") != ".,sub/dir,vend" || res.Workspace == "" {
|
||||
t.Errorf("result = %s", st.Result)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, res.Workspace, "tree")); err != nil {
|
||||
t.Errorf("the workspace is not in the work directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1485/AC2: a variable such as MESH_REGISTRY_PASSWORD or a *_TOKEN of the process is seen by no command of the step:
|
||||
// the runner builds the environment from scratch, and the step passes it nothing of the kind.
|
||||
func TestNoCommandOfTheStepSeesAPasswordOrAToken(t *testing.T) {
|
||||
t.Setenv("MESH_REGISTRY_PASSWORD", "hunter2-registry")
|
||||
t.Setenv("FORGE_TOKEN", "hunter2-token")
|
||||
t.Setenv("HOME", "/not/the/account")
|
||||
r := StepRunner("/home/account")
|
||||
got := r(context.Background(), []string{"EXTRA=1"}, "env")
|
||||
if got.Status != 0 {
|
||||
t.Fatalf("env: %+v", got)
|
||||
}
|
||||
for _, secret := range []string{"hunter2", "MESH_REGISTRY_PASSWORD", "FORGE_TOKEN", "/not/the/account"} {
|
||||
if strings.Contains(got.Stdout, secret) {
|
||||
t.Errorf("a command saw %q:\n%s", secret, got.Stdout)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"HOME=/home/account", "EXTRA=1", "PATH="} {
|
||||
if !strings.Contains(got.Stdout, want) {
|
||||
t.Errorf("a command lacks %q:\n%s", want, got.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(fetchArgs)
|
||||
states.waitEnd(t)
|
||||
for _, c := range f.calls {
|
||||
for _, w := range c.env {
|
||||
if k, _, _ := strings.Cut(w, "="); strings.HasSuffix(k, "TOKEN") || strings.HasSuffix(k, "PASSWORD") || strings.HasSuffix(k, "SECRET") {
|
||||
t.Errorf("%s was given %s", c.line, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1485/AC3: go mod verify failing ends the step failed naming the directory and the first line of its output.
|
||||
func TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\ntools/go.mod\n", goRule: func(line string) forgekey.Ran {
|
||||
if strings.HasSuffix(line, "tools mod verify") || strings.Contains(line, "/tools mod verify") {
|
||||
return forgekey.Ran{Status: 1, Stdout: "\nexample.org/x v1.0.0: dir has been modified\nsecond line\n"}
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}}
|
||||
m, states := fetchModule(t, f)
|
||||
if _, err := fetchTool(t, m)(fetchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepFailed {
|
||||
t.Fatalf("the step ended %s, want failed", st.State)
|
||||
}
|
||||
if !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, "example.org/x v1.0.0: dir has been modified") || strings.Contains(st.Reason, "second line") {
|
||||
t.Errorf("reason = %q", st.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 1485/AC4: the call answers a step id before the fetch ends, and the key then ends passed with the workspace.
|
||||
func TestTheCallAnswersAStepIdBeforeTheFetchEnds(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", gate: make(chan struct{})}
|
||||
m, states := fetchModule(t, f)
|
||||
out, err := fetchTool(t, m)(fetchArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := out.(map[string]any)
|
||||
id, _ := answer["step"].(string)
|
||||
if id == "" || answer["key"] != "ace."+id {
|
||||
t.Fatalf("answer = %v, want a step id and its key", answer)
|
||||
}
|
||||
select {
|
||||
case <-states.ended:
|
||||
t.Fatal("the step ended while git was held")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
states.mu.Lock()
|
||||
running := states.steps["ace."+id]
|
||||
states.mu.Unlock()
|
||||
if len(running) != 1 || running[0].State != delivery.StepRunning {
|
||||
t.Errorf("the key holds %+v, want one running step", running)
|
||||
}
|
||||
close(f.gate)
|
||||
select {
|
||||
case key := <-states.ended:
|
||||
if key != "ace."+id {
|
||||
t.Errorf("ended %s", key)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the step never ended")
|
||||
}
|
||||
states.mu.Lock()
|
||||
last := states.steps["ace."+id][len(states.steps["ace."+id])-1]
|
||||
states.mu.Unlock()
|
||||
var res FetchResult
|
||||
if last.State != delivery.StepPassed || json.Unmarshal(last.Result, &res) != nil || res.Workspace == "" {
|
||||
t.Errorf("the key ends %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARepositoryThatIsNotOwnerSlashNameIsRefusedBeforeAnythingRuns(t *testing.T) {
|
||||
for _, repo := range []string{"", "mesh-sdk", "../etc/passwd", "a/b/c", "a/..", "-o/x", "https://x/y"} {
|
||||
f := &fakeStep{}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(map[string]any{"repository": repo, "commit": commitSHA})
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepFailed || len(f.lines()) != 0 {
|
||||
t.Errorf("%q: the step ended %s after %d commands", repo, st.State, len(f.lines()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspaces older than a day are removed; younger ones, and anything that is not a workspace, stay.
|
||||
func TestWorkspacesOlderThanADayAreRemoved(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
for name, age := range map[string]time.Duration{"old1": 25 * time.Hour, "young": time.Hour, "Keep-This": 48 * time.Hour} {
|
||||
dir := filepath.Join(m.Work, name)
|
||||
os.MkdirAll(filepath.Join(dir, "tree"), 0o755)
|
||||
when := time.Now().Add(-age)
|
||||
os.Chtimes(dir, when, when)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
for name, want := range map[string]bool{"old1": false, "young": true, "Keep-This": true} {
|
||||
_, err := os.Stat(filepath.Join(m.Work, name))
|
||||
if (err == nil) != want {
|
||||
t.Errorf("%s present = %v, want %v", name, err == nil, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The work and cache directories the code uses are the ones the manifest places, and neither is in the home.
|
||||
func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
home := resource(t, m, "account")["home"].(string)
|
||||
for id, path := range map[string]string{"work": WorkDir, "cache": CacheDir} {
|
||||
if resource(t, m, id)["path"] != path {
|
||||
t.Errorf("%s: the manifest places %v, the code uses %s", id, resource(t, m, id)["path"], path)
|
||||
}
|
||||
if path == home || strings.HasPrefix(path, home+"/") {
|
||||
t.Errorf("%s %s is in the home %s", id, path, home)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
// The forge side of a build module (novox/hq ADR 0466 §2 and §4, issue 1484). This file is the same for every build
|
||||
// module: mesh-build-node takes a copy and changes the three constants below.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const (
|
||||
forgeModule = "mesh-build-go"
|
||||
forgeHome = "/var/lib/mesh-build-go"
|
||||
forgeModuleD = "../.." // the module's directory, where module.json and files/ are
|
||||
)
|
||||
|
||||
type forgeManifest struct {
|
||||
Requires []any `json:"requires"`
|
||||
State []any `json:"state"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readForgeManifest(t *testing.T) forgeManifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(forgeModuleD, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m forgeManifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func forgeTemplate(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(forgeModuleD, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func forgeResource(t *testing.T, m forgeManifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
if r["owner"] != forgeModule {
|
||||
t.Errorf("%s is owned by %v, not the module's own account", id, r["owner"])
|
||||
}
|
||||
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${dir:") || strings.Contains(p, "account-home") {
|
||||
t.Errorf("%s is at %q: a path of the account's own home (${dir:...}), never the operator's", id, p)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
var forgeSample = map[string]string{
|
||||
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
|
||||
"host_key": "ssh-ed25519 AAAAC3Nza",
|
||||
"ssh_host": "novox.internal",
|
||||
"ssh_alias": "mesh-forge",
|
||||
"ssh_port": "222",
|
||||
"ssh_base": "ssh://git@novox.internal:222/",
|
||||
"https_base": "https://git.novox.be/",
|
||||
}
|
||||
|
||||
// forgeRender fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
|
||||
func forgeRender(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
for k, v := range forgeSample {
|
||||
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
|
||||
}
|
||||
if strings.Contains(s, "${") {
|
||||
t.Errorf("unfilled placeholder in %q", s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 1484/AC1: given sample bound values, the ssh config has Host mesh-forge, IdentitiesOnly yes and the key under the
|
||||
// module's own home; known_hosts carries the host key; git's config rewrites https_base to ssh://mesh-forge/; no
|
||||
// ${ is left.
|
||||
func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
|
||||
cfg := forgeResource(t, m, "ssh-config")
|
||||
want := "Host mesh-forge\n HostName novox.internal\n Port 222\n HostKeyAlias mesh-forge\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
|
||||
if got := forgeRender(t, forgeTemplate(t, cfg)); got != want {
|
||||
t.Errorf("ssh config = %q, want %q", got, want)
|
||||
}
|
||||
if cfg["path"] != "${dir:ssh-dir}/config" || cfg["mode"] != "0600" || cfg["into"] != nil {
|
||||
t.Errorf("ssh config resource = %v, want the whole file ${dir:ssh-dir}/config at mode 0600", cfg)
|
||||
}
|
||||
|
||||
kh := forgeResource(t, m, "known-hosts")
|
||||
if got := strings.TrimSpace(forgeRender(t, forgeTemplate(t, kh))); got != "mesh-forge ssh-ed25519 AAAAC3Nza" {
|
||||
t.Errorf("known_hosts = %q", got)
|
||||
}
|
||||
|
||||
g := forgeResource(t, m, "git-config")
|
||||
wantGit := "[url \"ssh://mesh-forge/\"]\n\tinsteadOf = https://git.novox.be/\n"
|
||||
if got := forgeRender(t, forgeTemplate(t, g)); got != wantGit {
|
||||
t.Errorf("git config = %q, want %q", got, wantGit)
|
||||
}
|
||||
|
||||
dir := forgeResource(t, m, "ssh-dir")
|
||||
if dir["mode"] != "0700" || dir["path"] != "${dir:state}/.ssh" {
|
||||
t.Errorf("ssh-dir = %v, want ${dir:state}/.ssh at mode 0700", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// 1484/AC2: the module requires forge-account, keeps the state keys, and every file resource takes its text from a
|
||||
// template in the files/ archive (novox/hq ADR 0349 §2).
|
||||
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
if len(m.Requires) != 1 || m.Requires[0] != "forge-account" {
|
||||
t.Errorf("requires = %v, want [forge-account]", m.Requires)
|
||||
}
|
||||
keeps := false
|
||||
for _, s := range m.State {
|
||||
if s == "keys" {
|
||||
keeps = true
|
||||
}
|
||||
}
|
||||
if !keeps {
|
||||
t.Errorf("state = %v lacks keys", m.State)
|
||||
}
|
||||
files := 0
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
files++
|
||||
forgeTemplate(t, r)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
t.Errorf("no file resource: the forge's ssh files are missing")
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" && a["name"] == "files" {
|
||||
packs++
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives named files, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// forgeEd25519Line is a public key line with a well-formed ed25519 blob.
|
||||
func forgeEd25519Line(comment string) string {
|
||||
blob := []byte{}
|
||||
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
||||
l := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(l, uint32(len(part)))
|
||||
blob = append(append(blob, l...), part...)
|
||||
}
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
||||
}
|
||||
|
||||
type forgeKeys struct{ puts map[string][]byte }
|
||||
|
||||
func (f *forgeKeys) Put(key string, value any) (uint64, error) {
|
||||
raw, _ := json.Marshal(value)
|
||||
if f.puts == nil {
|
||||
f.puts = map[string][]byte{}
|
||||
}
|
||||
f.puts[key] = raw
|
||||
return uint64(len(f.puts)), nil
|
||||
}
|
||||
|
||||
// forgeKeygen stands in for ssh-keygen: it writes the pair the command names.
|
||||
func forgeKeygen(_ context.Context, _ []string, name string, args ...string) forgekey.Ran {
|
||||
var file, comment string
|
||||
for i, a := range args {
|
||||
switch a {
|
||||
case "-f":
|
||||
file = args[i+1]
|
||||
case "-C":
|
||||
comment = args[i+1]
|
||||
}
|
||||
}
|
||||
if name != "ssh-keygen" || file == "" {
|
||||
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
|
||||
}
|
||||
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
|
||||
os.WriteFile(file+".pub", []byte(forgeEd25519Line(comment)+"\n"), 0o644)
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
|
||||
// 1484/AC3: given the bundle started with an empty home, the keeper writes <node>.<module> with kind builder, and
|
||||
// the key is in the home's .ssh.
|
||||
func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) {
|
||||
home, st := t.TempDir(), &forgeKeys{}
|
||||
if err := keeperFor(home, "ace", forgeModule, forgeKeygen, st).Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
|
||||
t.Errorf("no key in an empty home: %v", err)
|
||||
}
|
||||
raw, ok := st.puts["ace."+forgeModule]
|
||||
if !ok || len(st.puts) != 1 {
|
||||
t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule)
|
||||
}
|
||||
var e forgekey.KeyEntry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Kind != "builder" || e.Node != "ace" || e.Account != forgeModule || !strings.HasPrefix(e.Public, "ssh-ed25519 ") {
|
||||
t.Errorf("entry = %s", raw)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||
t.Errorf("the entry carries private material: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The home the manifest places is the one the account is given, so the keeper's home and the templates' agree.
|
||||
func TestTheAccountsHomeIsTheOneTheManifestNames(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "user" && r["name"] == forgeModule && r["home"] != forgeHome {
|
||||
t.Errorf("the account's home is %v, want %s", r["home"], forgeHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
// The integration level (novox/hq ADR 0445 §2 §3, ADR 0470; issue 1486): `go test -tags integration` over the
|
||||
// workspace, against a throwaway postgres and a throwaway bus started for this run alone, handed to the tests as
|
||||
// MESH_TEST_POSTGRES and MESH_TEST_NATS. Its setup (compiling, the store, the bus) and its running time are measured and
|
||||
// judged apart, 30 s and 60 s; both servers are stopped and the run's directory removed whatever the tests did.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The integration level's bounds (ADR 0445 §3).
|
||||
const (
|
||||
IntegrationSetup = 30 * time.Second
|
||||
IntegrationRun = 60 * time.Second
|
||||
)
|
||||
|
||||
func (m *Module) integrationBounds() Bounds {
|
||||
b := m.IntegrationBounds
|
||||
if b.Setup <= 0 {
|
||||
b.Setup = IntegrationSetup
|
||||
}
|
||||
if b.Run <= 0 {
|
||||
b.Run = IntegrationRun
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (m *Module) integration(ctx context.Context, workspace, tree string, packages []string) (res *TestResult, err error) {
|
||||
major, err := m.checkPostgres(ctx)
|
||||
if err != nil {
|
||||
return nil, err // a *buildstep.Refusal: this level only
|
||||
}
|
||||
run := filepath.Join(m.Work, workspace, "run-"+buildstep.NewID())
|
||||
if err := os.MkdirAll(run, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The run's directory goes, and the servers stop, however the level ends: passed, failed, a panic.
|
||||
defer os.RemoveAll(run)
|
||||
|
||||
began := m.clock()
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postgres, stopStore, err := m.startStore(ctx, run)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the integration level's store: %w", err)
|
||||
}
|
||||
defer stopStore()
|
||||
nats, stopBus, err := m.startBus(ctx, filepath.Join(run, "nats"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", err)
|
||||
}
|
||||
defer stopBus()
|
||||
env := []string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}
|
||||
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
|
||||
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
|
||||
for _, d := range dirs {
|
||||
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", "-C", filepath.Join(tree, d), "test", "-tags", "integration", "-run", "^$", "-count=1", "./...")
|
||||
}
|
||||
setup := m.clock().Sub(began)
|
||||
|
||||
return m.runLevel(ctx, tree, levelOpts{
|
||||
level: LevelIntegration, tags: []string{"integration"}, packages: packages, bounds: m.integrationBounds(),
|
||||
env: env,
|
||||
setup: setup, wall: true,
|
||||
store: "postgres " + strconv.Itoa(major), bus: "nats-server " + m.Recipe.NatsServer,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// Issue 1486: the integration level, with the store and the bus faked at the process boundary: the commands the level
|
||||
// runs are recorded, and a clock the commands advance stands in for the time they would take.
|
||||
|
||||
type integFake struct {
|
||||
mu sync.Mutex
|
||||
calls []cmdRecord
|
||||
clock time.Time
|
||||
postgres string // what `postgres --version` prints
|
||||
captured string // the go test -json output of the run
|
||||
// advance is how long a phase takes on the fake clock: compile, start (of the store), test.
|
||||
advance map[string]time.Duration
|
||||
busURL string
|
||||
busDir string
|
||||
busDown bool
|
||||
runDirs []string // run directories seen when the store was started, to be gone at the end
|
||||
panicOn string // a phase that panics
|
||||
}
|
||||
|
||||
func newIntegFake(t *testing.T, captured string) *integFake {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "captured", captured+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &integFake{clock: time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC), postgres: "postgres (PostgreSQL) 18.6",
|
||||
captured: string(raw), advance: map[string]time.Duration{}, busURL: "nats://127.0.0.1:45222"}
|
||||
}
|
||||
|
||||
func (f *integFake) now() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.clock }
|
||||
|
||||
func (f *integFake) tick(phase string) {
|
||||
f.mu.Lock()
|
||||
f.clock = f.clock.Add(f.advance[phase])
|
||||
f.mu.Unlock()
|
||||
if f.panicOn == phase {
|
||||
panic("the " + phase + " phase panicked")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *integFake) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, cmdRecord{env: env, line: line})
|
||||
f.mu.Unlock()
|
||||
switch {
|
||||
case name == "postgres":
|
||||
return forgekey.Ran{Stdout: f.postgres + "\n"}
|
||||
case name == "git" && len(args) > 2 && args[2] == "ls-files":
|
||||
return forgekey.Ran{Stdout: "go.mod\n"}
|
||||
case name == "go" && strings.Contains(line, "-run ^$"):
|
||||
f.tick("compile")
|
||||
case name == "go":
|
||||
f.tick("test")
|
||||
status := 0
|
||||
if strings.Contains(f.captured, `"Action":"fail"`) {
|
||||
status = 1
|
||||
}
|
||||
return forgekey.Ran{Stdout: f.captured, Status: status}
|
||||
case name == "pg_ctl" && strings.HasSuffix(line, " start"):
|
||||
f.tick("start")
|
||||
for i, a := range args {
|
||||
if a == "-D" {
|
||||
f.runDirs = append(f.runDirs, filepath.Dir(filepath.Dir(args[i+1])))
|
||||
}
|
||||
}
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
|
||||
func (f *integFake) startBus(_ context.Context, dir string) (string, func(), error) {
|
||||
f.mu.Lock()
|
||||
f.busDir = dir
|
||||
f.mu.Unlock()
|
||||
return f.busURL, func() { f.mu.Lock(); f.busDown = true; f.mu.Unlock() }, nil
|
||||
}
|
||||
|
||||
func (f *integFake) lines() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []string{}
|
||||
for _, c := range f.calls {
|
||||
out = append(out, c.line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func integModule(t *testing.T, f *integFake) (*Module, *keptStates) {
|
||||
t.Helper()
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.1")
|
||||
m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir()
|
||||
m.Run, m.Now, m.StartBus = f.run, f.now, f.startBus
|
||||
if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m, states
|
||||
}
|
||||
|
||||
func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name != "build_go_test" {
|
||||
continue
|
||||
}
|
||||
if _, err := tool.Run(map[string]any{"workspace": "ws1", "level": level}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res TestResult
|
||||
if st.State == delivery.StepPassed {
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return res, st
|
||||
}
|
||||
|
||||
// 1486/AC2: the store's start carries listen_addresses=” and a socket directory under the run; the tests are given
|
||||
// its address and the bus's, and the run's directory is in the workspace's.
|
||||
func TestTheStoreListensOnASocketUnderTheRunAndNowhereElse(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
m, states := integModule(t, f)
|
||||
res, st := runLevelTool(t, m, states, "integration")
|
||||
if st.State != delivery.StepPassed || !res.Passed || res.Level != "integration" {
|
||||
t.Fatalf("the step ended %s (%s): %+v", st.State, st.Reason, res)
|
||||
}
|
||||
var start, initdb string
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c.line, "pg_ctl ") && strings.HasSuffix(c.line, " start") {
|
||||
start = c.line
|
||||
}
|
||||
if strings.HasPrefix(c.line, "initdb ") {
|
||||
initdb = c.line
|
||||
}
|
||||
}
|
||||
run := f.runDirs[0]
|
||||
if !strings.HasPrefix(run, filepath.Join(m.Work, "ws1", "run-")) {
|
||||
t.Errorf("the run directory is %s, not under the workspace's", run)
|
||||
}
|
||||
_, afterK, _ := strings.Cut(start, "-k ")
|
||||
socket, _, _ := strings.Cut(afterK, " ")
|
||||
if len(socket)+len(socketName) >= 100 {
|
||||
t.Errorf("the socket %q is too long for postgres", socket)
|
||||
}
|
||||
for _, want := range []string{"listen_addresses=''", "-k " + socket, "fsync=off", "-w"} {
|
||||
if !strings.Contains(start, want) {
|
||||
t.Errorf("the store's start %q lacks %q", start, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(start, "listen_addresses='*'") || strings.Contains(start, "-h ") || strings.Contains(start, "port=") {
|
||||
t.Errorf("the store listens on TCP: %s", start)
|
||||
}
|
||||
if !strings.Contains(initdb, "-D "+filepath.Join(run, "pg", "data")) || !strings.Contains(initdb, "--auth=trust") {
|
||||
t.Errorf("initdb = %q", initdb)
|
||||
}
|
||||
var test cmdRecord
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, "-json") {
|
||||
test = c
|
||||
}
|
||||
}
|
||||
if !strings.Contains(test.line, "-tags integration") {
|
||||
t.Errorf("the run does not ask for the tag: %s", test.line)
|
||||
}
|
||||
env := map[string]string{}
|
||||
for _, w := range test.env {
|
||||
k, v, _ := strings.Cut(w, "=")
|
||||
env[k] = v
|
||||
}
|
||||
u, err := url.Parse(env["MESH_TEST_POSTGRES"])
|
||||
if err != nil || u.Scheme != "postgres" || u.Query().Get("host") != socket || u.Host != "" {
|
||||
t.Errorf("MESH_TEST_POSTGRES = %q", env["MESH_TEST_POSTGRES"])
|
||||
}
|
||||
if strings.Count(strings.TrimPrefix(env["MESH_TEST_POSTGRES"], "postgres://"), "/") != 1 {
|
||||
t.Errorf("MESH_TEST_POSTGRES = %q has a slash beyond the one before the database: the controller's tests cut it at the last", env["MESH_TEST_POSTGRES"])
|
||||
}
|
||||
if env["MESH_TEST_NATS"] != "nats://127.0.0.1:45222" || f.busDir != filepath.Join(run, "nats") {
|
||||
t.Errorf("MESH_TEST_NATS = %q, bus dir %q", env["MESH_TEST_NATS"], f.busDir)
|
||||
}
|
||||
if res.Store != "postgres 18" || res.Bus != "nats-server "+m.Recipe.NatsServer {
|
||||
t.Errorf("the result names %q and %q", res.Store, res.Bus)
|
||||
}
|
||||
}
|
||||
|
||||
// 1486/AC3: a postgres of major 17 refuses the integration level, naming 17 and 18; the unit level still runs.
|
||||
func TestAnotherPostgresMajorRefusesTheIntegrationLevelOnly(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
f.postgres = "postgres (PostgreSQL) 17.2"
|
||||
m, states := integModule(t, f)
|
||||
_, st := runLevelTool(t, m, states, "integration")
|
||||
if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "17") || !strings.Contains(st.Reason, "18") {
|
||||
t.Fatalf("the integration step ended %s: %q", st.State, st.Reason)
|
||||
}
|
||||
if strings.Contains(f.lines(), "initdb") || strings.Contains(f.lines(), "-tags") {
|
||||
t.Errorf("the refused level ran something:\n%s", f.lines())
|
||||
}
|
||||
res, st := runLevelTool(t, m, states, "unit")
|
||||
if st.State != delivery.StepPassed || !res.Passed || res.Level != "unit" {
|
||||
t.Errorf("the unit level: %s %+v", st.State, res)
|
||||
}
|
||||
}
|
||||
|
||||
// 1486/AC4: a setup over thirty seconds fails the level naming the setup bound; a run over sixty names the running
|
||||
// bound and the slowest tests. The clock the commands move stands in for the time they take.
|
||||
func TestSetupAndRunningTimeAreJudgedApart(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
f.advance["compile"], f.advance["start"] = 20*time.Second, 15*time.Second
|
||||
m, states := integModule(t, f)
|
||||
res, _ := runLevelTool(t, m, states, "integration")
|
||||
if !res.Passed || !res.OverBound || res.OverWhich != "setup" || res.Setup != 35 || !strings.Contains(res.Why, "setup took 35.00 s, over its setup bound of 30 s") || strings.Contains(res.Why, "slowest tests") {
|
||||
t.Errorf("a setup of 35 s: %+v", res)
|
||||
}
|
||||
|
||||
g := newIntegFake(t, "slow")
|
||||
g.advance["test"] = 61 * time.Second
|
||||
m, states = integModule(t, g)
|
||||
res, _ = runLevelTool(t, m, states, "integration")
|
||||
if !res.Passed || res.OverWhich != "run" || res.Elapsed != 61 || !strings.Contains(res.Why, "took 61.00 s, over its bound of 60 s; the slowest tests: fixture/slow TestTakesAWhile") || strings.Contains(res.Why, "setup") {
|
||||
t.Errorf("a run of 61 s: %+v", res)
|
||||
}
|
||||
|
||||
h := newIntegFake(t, "slow")
|
||||
h.advance["compile"], h.advance["test"] = 29*time.Second, 59*time.Second
|
||||
m, states = integModule(t, h)
|
||||
if res, _ = runLevelTool(t, m, states, "integration"); !res.Passed || res.OverBound || res.Setup != 29 || res.Elapsed != 59 {
|
||||
t.Errorf("29 s of setup and 59 s of running are within both bounds: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 1486/AC5: a run that fails, and a run that panics: the store is stopped, the bus is stopped, the run's directory is
|
||||
// gone.
|
||||
func TestAFailedOrPanickedRunStopsTheServersAndRemovesTheRunDirectory(t *testing.T) {
|
||||
f := newIntegFake(t, "failing")
|
||||
m, states := integModule(t, f)
|
||||
res, _ := runLevelTool(t, m, states, "integration")
|
||||
if res.Passed {
|
||||
t.Fatalf("the failing fixture passed: %+v", res)
|
||||
}
|
||||
checkCleaned := func(f *integFake, m *Module) {
|
||||
t.Helper()
|
||||
lines := f.lines()
|
||||
if !strings.Contains(lines, "pg_ctl -D "+filepath.Join(f.runDirs[0], "pg", "data")+" -m immediate -w stop") {
|
||||
t.Errorf("the store was not stopped:\n%s", lines)
|
||||
}
|
||||
if strings.LastIndex(lines, " stop") < strings.LastIndex(lines, "-json") {
|
||||
t.Errorf("the store was stopped before the tests ended:\n%s", lines)
|
||||
}
|
||||
if !f.busDown {
|
||||
t.Errorf("the bus was not stopped")
|
||||
}
|
||||
if _, err := os.Stat(f.runDirs[0]); !os.IsNotExist(err) {
|
||||
t.Errorf("the run directory %s is still there (%v)", f.runDirs[0], err)
|
||||
}
|
||||
}
|
||||
checkCleaned(f, m)
|
||||
|
||||
g := newIntegFake(t, "failing")
|
||||
g.panicOn = "test"
|
||||
m, _ = integModule(t, g)
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
m.integration(context.Background(), "ws1", filepath.Join(m.Work, "ws1", "tree"), nil)
|
||||
}()
|
||||
checkCleaned(g, m)
|
||||
}
|
||||
|
||||
// A store that does not start ends the step failed, with the server stopped and the directory gone.
|
||||
func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
m, states := integModule(t, f)
|
||||
inner := m.Run
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
r := inner(ctx, env, name, args...)
|
||||
if name == "pg_ctl" && strings.HasSuffix(strings.Join(args, " "), " start") {
|
||||
return forgekey.Ran{Status: 1, Stderr: "pg_ctl: could not start server\n"}
|
||||
}
|
||||
return r
|
||||
}
|
||||
_, st := runLevelTool(t, m, states, "integration")
|
||||
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "pg_ctl: could not start server") {
|
||||
t.Errorf("the step ended %s: %q", st.State, st.Reason)
|
||||
}
|
||||
if !strings.Contains(f.lines(), " stop") {
|
||||
t.Errorf("the half-started store was not stopped:\n%s", f.lines())
|
||||
}
|
||||
if f.busDir != "" {
|
||||
t.Errorf("the bus was started after the store failed")
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1")); len(entries) != 1 || entries[0].Name() != "tree" {
|
||||
t.Errorf("the workspace holds %v, want only its tree", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePostgresMajorIsReadFromTheVersionLine(t *testing.T) {
|
||||
for line, want := range map[string]int{"postgres (PostgreSQL) 18.6": 18, "postgres (PostgreSQL) 9.6.24": 9, "postgres (PostgreSQL) 17.2 (Arch)": 17} {
|
||||
f := &integFake{postgres: line, advance: map[string]time.Duration{}}
|
||||
m := &Module{Run: f.run}
|
||||
if got, err := m.postgresMajor(context.Background()); err != nil || got != want {
|
||||
t.Errorf("%q = %d, %v; want %d", line, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := (&Module{Run: (&integFake{postgres: "no", advance: map[string]time.Duration{}}).run}).postgresMajor(context.Background()); err == nil {
|
||||
t.Errorf("accepted a line that is not postgres's")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1486 review: postgres refuses a socket path of 108 bytes and over. A run directory deep in the work directory
|
||||
// gets a short directory of its own for the socket, removed with the store; a short one keeps it under the run.
|
||||
func TestTheSocketPathStaysUnderTheLimitForALongRunDirectory(t *testing.T) {
|
||||
long := filepath.Join(t.TempDir(), strings.Repeat("deep", 20), "ws1", "run-mvh5uc96142806")
|
||||
if err := os.MkdirAll(long, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, short, err := socketDir(long)
|
||||
if err != nil || !short || len(dir)+len(socketName) >= 100 {
|
||||
t.Fatalf("socketDir(long) = %q, %v, %v", dir, short, err)
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Errorf("the short directory was not made: %v", err)
|
||||
}
|
||||
os.RemoveAll(dir)
|
||||
|
||||
near := t.TempDir()
|
||||
if dir, short, err := socketDir(near); err != nil || (len(near) < 50 && (short || !strings.HasPrefix(dir, near))) {
|
||||
t.Errorf("socketDir(short) = %q, %v, %v", dir, short, err)
|
||||
}
|
||||
|
||||
// Through the store: the short directory is removed when the store is stopped.
|
||||
f := newIntegFake(t, "slow")
|
||||
m, _ := integModule(t, f)
|
||||
_, stop, err := m.startStore(context.Background(), long)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sock string
|
||||
for _, c := range f.calls {
|
||||
if _, a, ok := strings.Cut(c.line, "-k "); ok && strings.HasPrefix(c.line, "pg_ctl") {
|
||||
sock, _, _ = strings.Cut(a, " ")
|
||||
}
|
||||
}
|
||||
if sock == "" || len(sock)+len(socketName) >= 100 {
|
||||
t.Fatalf("the store was started with the socket %q", sock)
|
||||
}
|
||||
stop()
|
||||
if _, err := os.Stat(sock); !os.IsNotExist(err) {
|
||||
t.Errorf("the short socket directory %s outlived the store (%v)", sock, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
// The module's own forge key (novox/hq ADR 0466 §2, issue 1484): mesh-sdk's key keeper, kind builder, for the
|
||||
// module's account in its home. It makes ~/.ssh/mesh_ed25519 when it is missing and writes one entry to this
|
||||
// module's `keys` state at <node>.<account>, which gitea's provider reads to register the key on the forge user
|
||||
// that reads and pushes nothing (ADR 0466 §3).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies
|
||||
// at <node>.<account> of the keys state.
|
||||
func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper {
|
||||
return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now}
|
||||
}
|
||||
|
||||
// startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a
|
||||
// check — there is no state to put the entry in.
|
||||
func startKeeper(ctx context.Context) {
|
||||
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
|
||||
u, err := user.Current()
|
||||
if node == "" || err != nil || u.HomeDir == "" {
|
||||
return
|
||||
}
|
||||
k := keeperFor(u.HomeDir, node, u.Username, forgekey.ExecRunner, keysState{})
|
||||
k.Keep(ctx, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-go] "+f+"\n", a...) })
|
||||
}
|
||||
|
||||
type keysState struct{}
|
||||
|
||||
func (keysState) Put(key string, value any) (uint64, error) {
|
||||
return stdio.State("keys").Put(key, value)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
// The judging of a test level (novox/hq ADR 0445 §2 §3; issues 1290 and 1486): `go test -json` read into a result
|
||||
// that says, per package, what ran and for how long, which tests failed and why, and whether the level kept its
|
||||
// bound. A level that failed is still a finished step: the verdict is in the result, and a step that ends failed is a
|
||||
// step that could not run.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The two levels (ADR 0445 §2).
|
||||
const (
|
||||
LevelUnit = "unit"
|
||||
LevelIntegration = "integration"
|
||||
)
|
||||
|
||||
// UnitBound is the unit level's one bound: its running time, five seconds for the level, no setup (ADR 0445 §3). The
|
||||
// level's time is that of its slowest package, as packages run side by side, the compile not counted.
|
||||
const UnitBound = 5 * time.Second
|
||||
|
||||
// Bounds are the bounds a level is judged by: Setup is the time of everything before the tests run (compiling,
|
||||
// starting the store and the bus; the unit level has none) and Run the tests' own time.
|
||||
type Bounds struct {
|
||||
Setup, Run time.Duration
|
||||
}
|
||||
|
||||
// PackageResult is one package's part of a level.
|
||||
type PackageResult struct {
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
Passed bool `json:"passed"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Tests int `json:"tests"`
|
||||
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
}
|
||||
|
||||
// TestTime is one test and the time it took.
|
||||
type TestTime struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
}
|
||||
|
||||
// Failure is a test that failed, or a package that failed with no test to name (a build that failed, a TestMain that
|
||||
// panicked); Output is the first lines it wrote.
|
||||
type Failure struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// TestResult is what a test step passes with.
|
||||
type TestResult struct {
|
||||
Level string `json:"level"`
|
||||
Passed bool `json:"passed"`
|
||||
// Why says, in words, why the level failed, or that it has no tests.
|
||||
Why string `json:"why,omitempty"`
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
Tests int `json:"tests"`
|
||||
Skipped int `json:"skipped"`
|
||||
Packages []PackageResult `json:"packages"`
|
||||
// Elapsed is the level's running time in seconds: the slowest package's.
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
// Setup is the level's setup time in seconds, when it has a setup.
|
||||
Setup float64 `json:"setup,omitempty"`
|
||||
// Bound and SetupBound are the bounds the level was judged by, in seconds.
|
||||
Bound float64 `json:"bound"`
|
||||
SetupBound float64 `json:"setup_bound,omitempty"`
|
||||
Slowest []TestTime `json:"slowest,omitempty"`
|
||||
Failed []Failure `json:"failed,omitempty"`
|
||||
// Store and Bus are what the integration level ran against: the postgres major version and the nats-server
|
||||
// version (ADR 0470 §4).
|
||||
// OverBound says a bound was kept no longer, and OverWhich which: setup, run or both. The level's verdict, Passed, is
|
||||
// the tests' own: the checker judges the bounds (ADR 0453 §9), with the slowest tests this result names.
|
||||
OverBound bool `json:"over_bound"`
|
||||
OverWhich string `json:"over_which,omitempty"`
|
||||
Store string `json:"store,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
}
|
||||
|
||||
// event is one line of `go test -json` (test2json, and the build events go test adds).
|
||||
type event struct {
|
||||
Action string
|
||||
Package string
|
||||
Test string
|
||||
Output string
|
||||
ImportPath string
|
||||
FailedBuild string
|
||||
Elapsed float64
|
||||
}
|
||||
|
||||
// outputLines is how many lines of output a failure keeps.
|
||||
const outputLines = 15
|
||||
|
||||
// reader collects the events of one go.mod directory's `go test -json` into a result.
|
||||
type reader struct {
|
||||
res *TestResult
|
||||
dir string
|
||||
pkgs map[string]*PackageResult
|
||||
order []string
|
||||
builds map[string][]string // build output by import path
|
||||
pkgOut map[string][]string
|
||||
testOut map[string][]string // by package + "\x00" + test
|
||||
times []TestTime
|
||||
failedIn map[string]bool // packages with a failed test
|
||||
}
|
||||
|
||||
func newReader(res *TestResult, dir string) *reader {
|
||||
return &reader{res: res, dir: dir, pkgs: map[string]*PackageResult{}, builds: map[string][]string{},
|
||||
pkgOut: map[string][]string{}, testOut: map[string][]string{}, failedIn: map[string]bool{}}
|
||||
}
|
||||
|
||||
// read takes go test's output. Lines that are not events are kept for a failure that has nothing else to say.
|
||||
func (r *reader) read(out string) (noise []string) {
|
||||
sc := bufio.NewScanner(strings.NewReader(out))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<26)
|
||||
for sc.Scan() {
|
||||
var ev event
|
||||
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
|
||||
if l := strings.TrimSpace(sc.Text()); l != "" {
|
||||
noise = append(noise, l)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.event(ev)
|
||||
}
|
||||
return noise
|
||||
}
|
||||
|
||||
func (r *reader) pkg(name string) *PackageResult {
|
||||
p := r.pkgs[name]
|
||||
if p == nil {
|
||||
p = &PackageResult{Dir: r.dir, Package: name}
|
||||
r.pkgs[name] = p
|
||||
r.order = append(r.order, name)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func keep(lines []string, out string) []string {
|
||||
if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") {
|
||||
lines = append(lines, t)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (r *reader) event(ev event) {
|
||||
switch {
|
||||
case ev.Package == "" && ev.ImportPath != "":
|
||||
if ev.Action == "build-output" {
|
||||
r.builds[ev.ImportPath] = keep(r.builds[ev.ImportPath], ev.Output)
|
||||
}
|
||||
case ev.Package == "":
|
||||
case ev.Test == "":
|
||||
p := r.pkg(ev.Package)
|
||||
switch ev.Action {
|
||||
case "output":
|
||||
r.pkgOut[ev.Package] = keep(r.pkgOut[ev.Package], ev.Output)
|
||||
case "pass":
|
||||
p.Passed, p.Elapsed = true, ev.Elapsed
|
||||
case "fail":
|
||||
p.Passed, p.Elapsed = false, ev.Elapsed
|
||||
if ev.FailedBuild != "" {
|
||||
r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...)
|
||||
}
|
||||
case "skip":
|
||||
p.Passed, p.NoTests = true, true
|
||||
}
|
||||
default:
|
||||
key := ev.Package + "\x00" + ev.Test
|
||||
switch ev.Action {
|
||||
case "output":
|
||||
r.testOut[key] = keep(r.testOut[key], ev.Output)
|
||||
case "pass", "fail", "skip":
|
||||
p := r.pkg(ev.Package)
|
||||
p.Tests++
|
||||
r.res.Tests++
|
||||
if ev.Action == "skip" {
|
||||
r.res.Skipped++
|
||||
}
|
||||
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
||||
if ev.Action == "fail" {
|
||||
r.failedIn[ev.Package] = true
|
||||
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key])})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clip(lines []string) string {
|
||||
if len(lines) > outputLines {
|
||||
lines = append(lines[:outputLines:outputLines], fmt.Sprintf("... (%d more lines)", len(lines)-outputLines))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// finish moves what was read into the result: the packages in order, a failure for each failed package that has no
|
||||
// failed test to name it, and the tests' times.
|
||||
func (r *reader) finish() {
|
||||
for _, name := range r.order {
|
||||
p := r.pkgs[name]
|
||||
if !p.Passed && !r.failedIn[name] {
|
||||
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name])})
|
||||
}
|
||||
if !p.NoTests && p.Elapsed > r.res.Elapsed {
|
||||
r.res.Elapsed = p.Elapsed
|
||||
}
|
||||
r.res.Packages = append(r.res.Packages, *p)
|
||||
}
|
||||
r.res.Slowest = append(r.res.Slowest, r.times...)
|
||||
}
|
||||
|
||||
// judge sets Passed and Why from what was read and the bounds. A level with no failure, over no bound, passes; with no
|
||||
// test at all it says the repository has none at this level, which is neither a failure nor a warning (ADR 0445 §1).
|
||||
func (res *TestResult) judge(b Bounds) {
|
||||
sort.SliceStable(res.Slowest, func(i, j int) bool { return res.Slowest[i].Elapsed > res.Slowest[j].Elapsed })
|
||||
if len(res.Slowest) > 5 {
|
||||
res.Slowest = res.Slowest[:5]
|
||||
}
|
||||
res.Bound, res.SetupBound = b.Run.Seconds(), b.Setup.Seconds()
|
||||
var why, over []string
|
||||
if n := len(res.Failed); n > 0 {
|
||||
names := []string{}
|
||||
for _, f := range res.Failed {
|
||||
names = append(names, strings.TrimSpace(f.Package+" "+f.Test))
|
||||
}
|
||||
why = append(why, fmt.Sprintf("%d failed: %s", n, strings.Join(names, ", ")))
|
||||
}
|
||||
res.Passed = len(res.Failed) == 0
|
||||
if b.Setup > 0 && res.Setup > b.Setup.Seconds() {
|
||||
over = append(over, "setup")
|
||||
why = append(why, fmt.Sprintf("the %s level's setup took %.2f s, over its setup bound of %s", res.Level, res.Setup, seconds(b.Setup)))
|
||||
}
|
||||
if b.Run > 0 && res.Elapsed > b.Run.Seconds() {
|
||||
over = append(over, "run")
|
||||
slow := []string{}
|
||||
for _, s := range res.Slowest {
|
||||
slow = append(slow, fmt.Sprintf("%s %s (%.2f s)", s.Package, s.Test, s.Elapsed))
|
||||
}
|
||||
why = append(why, fmt.Sprintf("the %s level took %.2f s, over its bound of %s; the slowest tests: %s", res.Level, res.Elapsed, seconds(b.Run), strings.Join(slow, ", ")))
|
||||
}
|
||||
res.OverBound, res.OverWhich = len(over) > 0, strings.Join(over, ",")
|
||||
res.NoTests = res.Tests == 0 && len(res.Failed) == 0
|
||||
switch {
|
||||
case len(why) > 0:
|
||||
res.Why = strings.Join(why, "; ")
|
||||
case res.NoTests:
|
||||
res.Why = "the repository has no tests at the " + res.Level + " level"
|
||||
}
|
||||
}
|
||||
|
||||
// seconds is a duration as seconds, without trailing zeros: "5 s", "0.01 s".
|
||||
func seconds(d time.Duration) string {
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", d.Seconds()), "0"), ".") + " s"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// mesh-build-go: the Go build module (novox/hq ADR 0405, ADR 0466 §1, ADR 0469, ADR 0470; design 47 R4c-3, issue
|
||||
// 1483). A Go tools bundle the node's runtime launches as the module's own account (runs-as). It names the one Go
|
||||
// toolchain version it builds with, writes the version it finds to <node>.go of its `toolchains` state at start and
|
||||
// before every step, and refuses every step on a node that has another: its health is then unhealthy, so the
|
||||
// controller raises the node's condition. What it says, it says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The directories the module's manifest places outside its home (ADR 0466 §1): the workspaces, and the Go module
|
||||
// and build caches.
|
||||
const (
|
||||
WorkDir = "/var/lib/mesh-build-go-work"
|
||||
CacheDir = "/var/lib/mesh-build-go-cache"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-go] "+format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == busArg {
|
||||
os.Exit(runBus(os.Args[2:]))
|
||||
}
|
||||
ctx := context.Background()
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Recipe: Current(),
|
||||
Probe: ProbeGo,
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Work: WorkDir,
|
||||
Cache: CacheDir,
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
m.Home = u.HomeDir
|
||||
}
|
||||
m.Run = StepRunner(m.Home)
|
||||
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
|
||||
if m.Node != "" {
|
||||
go m.Start(ctx)
|
||||
}
|
||||
go startKeeper(ctx)
|
||||
if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The catalogue's root and this module's directory.
|
||||
const (
|
||||
catalogueRoot = "../../../.."
|
||||
moduleDir = "../.."
|
||||
)
|
||||
|
||||
func readManifest(t *testing.T) catalogue.Manifest {
|
||||
t.Helper()
|
||||
m, err := catalogue.ReadTreeManifest(catalogueRoot, moduleDir)
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-sdk's validator refuses module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("module.json declares no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1483/AC1: mesh-sdk's validator accepts the module (alone on a shelf too), and it runs as mesh-build-go with its
|
||||
// home at mode 0700, requires user-namespaces and declares the four packages.
|
||||
func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if problems := catalogue.CatalogueProblems(catalogue.Shelf{m.Module: m}); len(problems) > 0 {
|
||||
t.Errorf("the catalogue's rules refuse it: %v", problems)
|
||||
}
|
||||
if m.Module != "mesh-build-go" || m.RunsAs != "mesh-build-go" {
|
||||
t.Errorf("module %q runs as %q, want mesh-build-go as itself", m.Module, m.RunsAs)
|
||||
}
|
||||
account := resource(t, m, "account")
|
||||
if account["type"] != "user" || account["name"] != "mesh-build-go" || account["shell"] != "/usr/bin/nologin" || account["home"] != "/var/lib/mesh-build-go" {
|
||||
t.Errorf("account = %v", account)
|
||||
}
|
||||
home := resource(t, m, "state")
|
||||
if home["type"] != "directory" || home["mode"] != "0700" || home["owner"] != "mesh-build-go" || home["place"] != "." {
|
||||
t.Errorf("home = %v, want its directory at mode 0700 owned by the account", home)
|
||||
}
|
||||
if !slices.Contains(m.Capabilities, "user-namespaces") {
|
||||
t.Errorf("capabilities = %v, want user-namespaces", m.Capabilities)
|
||||
}
|
||||
packages := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
packages = append(packages, r["package"].(string))
|
||||
}
|
||||
}
|
||||
slices.Sort(packages)
|
||||
if want := []string{"git", "go", "openssh", "postgresql"}; !slices.Equal(packages, want) {
|
||||
t.Errorf("packages = %v, want %v", packages, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The work and cache directories are outside the home (ADR 0466 §1), owned by the account.
|
||||
func TestWorkAndCacheDirectoriesLieOutsideTheHome(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, id := range []string{"work", "cache"} {
|
||||
r := resource(t, m, id)
|
||||
path, _ := r["path"].(string)
|
||||
if path == "" || path == "/var/lib/mesh-build-go" || len(path) > len("/var/lib/mesh-build-go") && path[:len("/var/lib/mesh-build-go/")] == "/var/lib/mesh-build-go/" {
|
||||
t.Errorf("%s is at %q, inside or at the home", id, path)
|
||||
}
|
||||
if r["owner"] != "mesh-build-go" {
|
||||
t.Errorf("%s is owned by %v", id, r["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The states are the three the module keeps, no seat is claimed (ADR 0469 §6), and the health is the module's own
|
||||
// tool, which the manifest also lists among its tools.
|
||||
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
states := []string{}
|
||||
for _, s := range m.State {
|
||||
states = append(states, s.Name)
|
||||
}
|
||||
slices.Sort(states)
|
||||
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if len(m.Claims) != 0 {
|
||||
t.Errorf("claims = %v, want no seat", m.Claims)
|
||||
}
|
||||
if !slices.Contains(m.Tools, HealthTool) {
|
||||
t.Errorf("tools = %v lack %s", m.Tools, HealthTool)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) {
|
||||
t.Errorf("runtime-health does not name %s", HealthTool)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool the manifest names is served, and every tool served is named.
|
||||
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
served := []string{}
|
||||
for _, tool := range (&Module{}).Tools((&Module{}).StepTools()) {
|
||||
served = append(served, tool.Name)
|
||||
}
|
||||
slices.Sort(served)
|
||||
named := slices.Clone(m.Tools)
|
||||
slices.Sort(named)
|
||||
if !slices.Equal(served, named) {
|
||||
t.Errorf("served %v, manifest names %v", served, named)
|
||||
}
|
||||
}
|
||||
|
||||
// 1482/AC4: gitea's manifest reads the keys state of both build modules, and the catalogue's validator accepts it with the
|
||||
// build modules beside it: each owner present declares the state read (a read of a state its owner does not keep is
|
||||
// refused).
|
||||
func TestGiteaReadsTheBuildModulesKeysAndTheCatalogueAcceptsThemTogether(t *testing.T) {
|
||||
var all []catalogue.Manifest
|
||||
var gitea catalogue.Manifest
|
||||
for _, name := range []string{"gitea", "mesh-build-go", "mesh-build-node"} {
|
||||
m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-sdk's validator refuses modules/%s/module.json: %v", name, err)
|
||||
}
|
||||
all = append(all, m)
|
||||
if name == "gitea" {
|
||||
gitea = m
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"mesh-build-go.keys", "mesh-build-node.keys", "ssh-client.keys"} {
|
||||
if !slices.Contains(gitea.Reads, want) {
|
||||
t.Errorf("gitea reads %v, lacks %s", gitea.Reads, want)
|
||||
}
|
||||
}
|
||||
if problems := catalogue.StateReadsNothingDeclares(all); len(problems) > 0 {
|
||||
t.Errorf("a read names a state its owner does not keep: %v", problems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// CheckRound is how often the module reads the installed toolchain again, beside the check before every step and
|
||||
// the one every health look makes.
|
||||
const CheckRound = 10 * time.Minute
|
||||
|
||||
// Module is the Go build module's one running state: the node it runs on, its recipe, the toolchain guard and the
|
||||
// states it writes (toolchains, steps).
|
||||
type Module struct {
|
||||
Node string
|
||||
Recipe Recipe
|
||||
// Probe reads the installed Go version (ProbeGo).
|
||||
Probe func(ctx context.Context) (string, error)
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
|
||||
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
|
||||
Emit func(eventType string, body any) error
|
||||
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
|
||||
Ctx context.Context
|
||||
Say func(format string, a ...any)
|
||||
// Run runs the commands of a step (StepRunner); Work holds the workspaces and Cache the module and build caches,
|
||||
// both outside the account's home (ADR 0466 §1); Home is the account's home.
|
||||
Run forgekey.Runner
|
||||
Home, Work, Cache string
|
||||
// UnitBound is the unit level's bound; zero is UnitBound. A test lowers it to see a level over its bound.
|
||||
UnitBound time.Duration
|
||||
// IntegrationBounds are the integration level's bounds; zero values are IntegrationSetup and IntegrationRun.
|
||||
IntegrationBounds Bounds
|
||||
// Now is the clock the levels' times are read from; time.Now when nil.
|
||||
Now func() time.Time
|
||||
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
|
||||
// program, StartBus in bus.go, when nil).
|
||||
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
|
||||
}
|
||||
|
||||
func (m *Module) guard() buildstep.Guard {
|
||||
return buildstep.Guard{Language: "go", Want: m.Recipe.Go, Recipe: m.Recipe.Version(), Probe: m.Probe, Node: m.Node, Put: m.reportingPut()}
|
||||
}
|
||||
|
||||
// Check reads the installed toolchain, writes <node>.go of the toolchains state and answers nil, or the refusal
|
||||
// (buildstep.Guard.Check). It runs at start, before every step, at every health look and every CheckRound.
|
||||
func (m *Module) Check(ctx context.Context) error {
|
||||
return m.guard().Check(ctx)
|
||||
}
|
||||
|
||||
// Start checks once, as the module starts, and then every CheckRound until ctx ends.
|
||||
func (m *Module) Start(ctx context.Context) {
|
||||
for {
|
||||
if err := m.Check(ctx); err != nil && m.Say != nil {
|
||||
m.Say("the toolchain: %v", err)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(CheckRound):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy while the toolchain installed is not
|
||||
// the recipe's, saying both versions, so the controller raises the node's condition. It looks again each time it is
|
||||
// asked.
|
||||
func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
err := m.Check(ctx)
|
||||
switch {
|
||||
case err == nil:
|
||||
return health.Healthy(fmt.Sprintf("%s installed, as the recipe %s builds with", m.Recipe.Go, m.Recipe.Version()))
|
||||
default:
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on
|
||||
// its key in the steps state. The guard runs inside the step, before run, so a node that refuses ends every step
|
||||
// refused with both versions named.
|
||||
func (m *Module) Long(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, _ := delivery.StepKey(m.Node, id)
|
||||
return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Short is a tool that answers in its own call; the guard runs before it, and a refusal is the call's error.
|
||||
func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
if err := m.Check(m.Ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(m.Ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Status is what the module reports of itself: the node, the recipe, the toolchain found and why it refuses, if it
|
||||
// does.
|
||||
func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
err := m.Check(ctx)
|
||||
installed, _ := m.Probe(ctx)
|
||||
out := map[string]any{
|
||||
"node": m.Node, "recipe": m.Recipe.Version(), "go": m.Recipe.Go, "installed": installed,
|
||||
"postgres-major": m.Recipe.PostgresMajor, "nats-server": m.Recipe.NatsServer, "refusing": err != nil,
|
||||
}
|
||||
if err != nil {
|
||||
out["why"] = err.Error()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolchainReported is the event the guard emits after it writes the toolchains key.
|
||||
const ToolchainReported = "toolchain.reported"
|
||||
|
||||
// reportingPut is the toolchains Put that then emits toolchain.reported with the node and the language. A write that
|
||||
// failed emits nothing; an emit that failed is said and does not refuse the step.
|
||||
func (m *Module) reportingPut() buildstep.Put {
|
||||
return func(key string, value any) (uint64, error) {
|
||||
rev, err := m.Toolchains(key, value)
|
||||
if err != nil || m.Emit == nil {
|
||||
return rev, err
|
||||
}
|
||||
if eerr := m.Emit(ToolchainReported, map[string]string{"node": m.Node, "language": "go"}); eerr != nil && m.Say != nil {
|
||||
m.Say("the event %s could not be emitted: %v", ToolchainReported, eerr)
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// keptStates stands in for the toolchains and the steps states.
|
||||
type keptStates struct {
|
||||
mu sync.Mutex
|
||||
toolchains map[string]delivery.Toolchain
|
||||
steps map[string][]delivery.Step
|
||||
ended chan string
|
||||
}
|
||||
|
||||
func newStates() *keptStates {
|
||||
return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)}
|
||||
}
|
||||
|
||||
func (s *keptStates) putToolchain(key string, value any) (uint64, error) {
|
||||
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.toolchains[key] = tc
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *keptStates) putStep(key string, value any) (uint64, error) {
|
||||
st, err := delivery.DecodeStep(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.steps[key] = append(s.steps[key], st)
|
||||
s.mu.Unlock()
|
||||
if st.State != delivery.StepRunning {
|
||||
s.ended <- key
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tc, ok := s.toolchains[key]
|
||||
return tc, ok
|
||||
}
|
||||
|
||||
func (s *keptStates) waitEnd(t *testing.T) delivery.Step {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-s.ended:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v := s.steps[key]
|
||||
return v[len(v)-1]
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return delivery.Step{}
|
||||
}
|
||||
}
|
||||
|
||||
func moduleWith(states *keptStates, installed string) *Module {
|
||||
return &Module{Node: "ace", Recipe: Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"},
|
||||
Probe: func(context.Context) (string, error) { return installed, nil },
|
||||
Toolchains: states.putToolchain, Steps: states.putStep, Ctx: context.Background()}
|
||||
}
|
||||
|
||||
// 1483/AC2: a probe answering go1.27.1: the bundle's start writes ace.go with that version and the recipe's, and
|
||||
// the health tool answers healthy.
|
||||
func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.1")
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
go m.Start(ctx)
|
||||
var tc delivery.Toolchain
|
||||
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
||||
if got, ok := states.toolchain("ace.go"); ok {
|
||||
tc = got
|
||||
break
|
||||
}
|
||||
}
|
||||
if tc.Version != "go1.27.1" || tc.Recipe != m.Recipe.Version() || tc.Language != "go" {
|
||||
t.Fatalf("ace.go = %+v, want go1.27.1 and recipe %s", tc, m.Recipe.Version())
|
||||
}
|
||||
if a := m.Health(ctx); a.State != health.StateHealthy || !a.Healthy {
|
||||
t.Errorf("health = %+v, want healthy", a)
|
||||
}
|
||||
}
|
||||
|
||||
// 1483/AC3: a probe answering go1.27.2: every step tool's step ends refused naming both versions, ace.go holds
|
||||
// go1.27.2, and the health tool answers unhealthy naming both.
|
||||
func TestAnotherGoRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.2")
|
||||
ran := false
|
||||
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
|
||||
steps := append([]StepTool{
|
||||
{Name: "long_probe", Long: true, Run: run},
|
||||
{Name: "short_probe", Run: run},
|
||||
}, m.StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
out, err := tool.Run(map[string]any{})
|
||||
if isLong(steps, tool.Name) {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name, err)
|
||||
}
|
||||
if out.(map[string]any)["step"] == "" {
|
||||
t.Fatalf("%s answered no step id: %v", tool.Name, out)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepRefused {
|
||||
t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State)
|
||||
}
|
||||
reason = st.Reason
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: answered %v, want a refusal", tool.Name, out)
|
||||
}
|
||||
reason = err.Error()
|
||||
}
|
||||
if !strings.Contains(reason, "go1.27.2") || !strings.Contains(reason, "go1.27.1") {
|
||||
t.Errorf("%s: the refusal %q does not name both versions", tool.Name, reason)
|
||||
}
|
||||
}
|
||||
if ran {
|
||||
t.Errorf("a step ran on a node that refuses")
|
||||
}
|
||||
if tc, _ := states.toolchain("ace.go"); tc.Version != "go1.27.2" {
|
||||
t.Errorf("ace.go holds %q, want the installed go1.27.2", tc.Version)
|
||||
}
|
||||
a := m.Health(context.Background())
|
||||
if a.State != health.StateUnhealthy || !strings.Contains(a.Why, "go1.27.2") || !strings.Contains(a.Why, "go1.27.1") {
|
||||
t.Errorf("health = %+v, want unhealthy naming both versions", a)
|
||||
}
|
||||
}
|
||||
|
||||
func isLong(steps []StepTool, name string) bool {
|
||||
for _, s := range steps {
|
||||
if s.Name == name {
|
||||
return s.Long
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1483/AC4: recipes differing only in the postgres major or the nats-server version have different recipe versions;
|
||||
// equal recipes have the same one.
|
||||
func TestRecipeVersionMovesWithEachOfItsValues(t *testing.T) {
|
||||
base := Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"}
|
||||
if base.Version() != (Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"}).Version() {
|
||||
t.Errorf("one recipe has two versions")
|
||||
}
|
||||
seen := map[string]string{base.Version(): "base"}
|
||||
for name, r := range map[string]Recipe{
|
||||
"postgres major": {Steps: "1", Go: "go1.27.1", PostgresMajor: 19, NatsServer: "2.11.17"},
|
||||
"nats-server": {Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.18"},
|
||||
"go": {Steps: "1", Go: "go1.27.2", PostgresMajor: 18, NatsServer: "2.11.17"},
|
||||
"steps": {Steps: "2", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"},
|
||||
} {
|
||||
v := r.Version()
|
||||
if was, dup := seen[v]; dup {
|
||||
t.Errorf("the recipe differing in %s has the version of the %s recipe: %s", name, was, v)
|
||||
}
|
||||
seen[v] = name
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRecipeNamesGo1271AndPostgres18AndTheLibrarysNatsVersion(t *testing.T) {
|
||||
r := Current()
|
||||
if r.Go != "go1.27.1" || r.PostgresMajor != 18 {
|
||||
t.Errorf("recipe = %+v", r)
|
||||
}
|
||||
if r.NatsServer == "" || strings.HasPrefix(r.NatsServer, "v") {
|
||||
t.Errorf("nats-server version = %q", r.NatsServer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGoVersion(t *testing.T) {
|
||||
if v, err := parseGoVersion("go version go1.27.1 linux/amd64\n"); err != nil || v != "go1.27.1" {
|
||||
t.Errorf("= %q, %v", v, err)
|
||||
}
|
||||
if _, err := parseGoVersion("not go"); err == nil {
|
||||
t.Errorf("accepted a text that is not `go version`'s")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProbeTimeout bounds `go version`.
|
||||
const ProbeTimeout = 15 * time.Second
|
||||
|
||||
// ProbeGo reads the installed Go version: the third word `go version` prints ("go version go1.27.1 linux/amd64"),
|
||||
// as the recipe's RecipeGo is written. GOTOOLCHAIN=local keeps an older Go from fetching another toolchain: what is
|
||||
// installed is what is read.
|
||||
func ProbeGo(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "version")
|
||||
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseGoVersion(string(out))
|
||||
}
|
||||
|
||||
func parseGoVersion(out string) (string, error) {
|
||||
f := strings.Fields(out)
|
||||
if len(f) < 3 || f[0] != "go" || f[1] != "version" || !strings.HasPrefix(f[2], "go") {
|
||||
return "", fmt.Errorf("`go version` answered %q", strings.TrimSpace(out))
|
||||
}
|
||||
return f[2], nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
// The recipe of the Go build module (novox/hq ADR 0405 §1 and §7, ADR 0469 §1, ADR 0470 §4; issue 1483): the one
|
||||
// Go toolchain version it builds with, the one postgres major version its integration level tests with, and the
|
||||
// recipe's own version. A node whose installed Go differs from the recipe's refuses every step (guard.go), so all
|
||||
// builders run this version or none builds.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
// RecipeGo is the Go version the recipe builds with: the text `go version` prints for it, which is what the guard
|
||||
// compares and what the indexer reads from <node>.go of the toolchains state.
|
||||
const RecipeGo = "go1.27.1"
|
||||
|
||||
// PostgresMajor is the postgres major version the integration level tests with (ADR 0470 §4).
|
||||
const PostgresMajor = 18
|
||||
|
||||
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way
|
||||
// that can change what they produce (ADR 0405 §7). It is one of the four values the recipe version hashes.
|
||||
const RecipeSteps = "1"
|
||||
|
||||
// natsModule is the library the integration level's bus is started from (ADR 0470 §2).
|
||||
const natsModule = "github.com/nats-io/nats-server/v2"
|
||||
|
||||
// Recipe is every value that makes up the recipe version.
|
||||
type Recipe struct {
|
||||
// Steps is RecipeSteps.
|
||||
Steps string
|
||||
// Go is the toolchain's version as `go version` prints it.
|
||||
Go string
|
||||
// PostgresMajor is the integration level's postgres major version.
|
||||
PostgresMajor int
|
||||
// NatsServer is the nats-server library's version, from the program's build information.
|
||||
NatsServer string
|
||||
}
|
||||
|
||||
// Current is the recipe of this build of the module.
|
||||
func Current() Recipe {
|
||||
return Recipe{Steps: RecipeSteps, Go: RecipeGo, PostgresMajor: PostgresMajor, NatsServer: natsServerVersion()}
|
||||
}
|
||||
|
||||
// Version is the recipe version: a hash of the recipe's own version, the Go version, the postgres major version and
|
||||
// the nats-server version, so a change of any of the four moves it and with it every fingerprint that holds it
|
||||
// (ADR 0405 §7, ADR 0465 §2). The fields are named in the hashed text, so no two recipes share a text.
|
||||
func (r Recipe) Version() string {
|
||||
h := sha256.New()
|
||||
for _, f := range [][2]string{
|
||||
{"steps", r.Steps}, {"go", r.Go}, {"postgres", strconv.Itoa(r.PostgresMajor)}, {"nats-server", r.NatsServer},
|
||||
} {
|
||||
h.Write([]byte(f[0] + "=" + f[1] + "\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// natsServerVersion is the version of the nats-server library this program was built with: its build information
|
||||
// names it as the module's go.mod does, and has no second source (ADR 0470 §4). A binary built without build
|
||||
// information (a stripped test binary) falls back to the library's own version constant, which is the same text.
|
||||
func natsServerVersion() string {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if v := natsVersionOf(info); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return server.VERSION
|
||||
}
|
||||
|
||||
// natsVersionOf is the version build information gives the nats-server library, "v" stripped, or "" when it lists
|
||||
// none. A replaced library is the replacement's version.
|
||||
func natsVersionOf(info *debug.BuildInfo) string {
|
||||
for _, d := range info.Deps {
|
||||
if d.Path != natsModule {
|
||||
continue
|
||||
}
|
||||
if d.Replace != nil {
|
||||
d = d.Replace
|
||||
}
|
||||
return strings.TrimPrefix(d.Version, "v")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// StepRunner runs the commands of a build step with an environment built from scratch (novox/hq ADR 0466 §4, issue
|
||||
// 1485): PATH, HOME (the account's own home, where its ssh files and git configuration are) and a fixed locale, plus
|
||||
// the words the caller passes, and nothing else of this process's environment — which holds the module's bus
|
||||
// credentials and might hold a password or a token. It is mesh-sdk's clean runner (ExecRunnerClean, issue 1497) with
|
||||
// that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty
|
||||
// seconds (the step has its own context).
|
||||
func StepRunner(home string) forgekey.Runner {
|
||||
run := forgekey.ExecRunnerClean(0)
|
||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
return run(ctx, append(baseEnv(home), env...), name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultPath is where a step looks for git, ssh and go when this process was given no PATH.
|
||||
const defaultPath = "/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
func baseEnv(home string) []string {
|
||||
path := os.Getenv("PATH")
|
||||
if path == "" {
|
||||
path = defaultPath
|
||||
}
|
||||
return []string{"PATH=" + path, "HOME=" + home, "LC_ALL=C"}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
// The integration level's throwaway store (novox/hq ADR 0470 §1; issue 1486): one postgres server of the installed
|
||||
// package for each run, `initdb` into the run's own directory, listening on a unix socket in that directory and on no
|
||||
// TCP address, as the module's account, with durability off as today's check sets it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// storeStopBound is how long stopping the store is given: it runs after the level, whatever the level did.
|
||||
const storeStopBound = 30 * time.Second
|
||||
|
||||
var postgresVersion = regexp.MustCompile(`PostgreSQL\)\s+(\d+)`)
|
||||
|
||||
// postgresMajor is the major version of the installed postgres: `postgres --version` ("postgres (PostgreSQL) 18.6").
|
||||
func (m *Module) postgresMajor(ctx context.Context) (int, error) {
|
||||
r := m.Run(ctx, nil, "postgres", "--version")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return 0, fmt.Errorf("`postgres --version`: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
f := postgresVersion.FindStringSubmatch(r.Stdout)
|
||||
if f == nil {
|
||||
return 0, fmt.Errorf("`postgres --version` answered %q", firstLine(r.Stdout))
|
||||
}
|
||||
return strconv.Atoi(f[1])
|
||||
}
|
||||
|
||||
// checkPostgres refuses the integration level when the installed postgres major is not the recipe's, naming both
|
||||
// (ADR 0470 §4); the unit level is not refused. It answers the major it found.
|
||||
func (m *Module) checkPostgres(ctx context.Context) (int, error) {
|
||||
major, err := m.postgresMajor(ctx)
|
||||
if err != nil {
|
||||
return 0, &buildstep.Refusal{Text: fmt.Sprintf("the installed postgres version could not be read: %v", err), Want: strconv.Itoa(m.Recipe.PostgresMajor)}
|
||||
}
|
||||
if major != m.Recipe.PostgresMajor {
|
||||
return major, &buildstep.Refusal{Text: fmt.Sprintf("postgres %d installed, the integration level is built for postgres %d", major, m.Recipe.PostgresMajor),
|
||||
Want: strconv.Itoa(m.Recipe.PostgresMajor), Have: strconv.Itoa(major)}
|
||||
}
|
||||
return major, nil
|
||||
}
|
||||
|
||||
// socketLimit is the longest path of a socket postgres accepts, with the file's name: it refuses 108 bytes and over.
|
||||
const socketLimit = 100
|
||||
|
||||
// socketName is the file postgres makes in the socket directory.
|
||||
const socketName = "/.s.PGSQL.5432"
|
||||
|
||||
// socketDir makes the directory of the store's socket: under the run when the socket's path stays under socketLimit,
|
||||
// else a short directory of its own under /tmp (answering short, so the caller removes it). A unix socket's path is
|
||||
// bounded far below a file's, and a work directory deep in a data root is over it.
|
||||
func socketDir(run string) (dir string, short bool, err error) {
|
||||
dir = filepath.Join(run, "pg", "socket")
|
||||
if len(dir)+len(socketName) < socketLimit {
|
||||
return dir, false, os.MkdirAll(dir, 0o700)
|
||||
}
|
||||
dir, err = os.MkdirTemp("/tmp", "pg")
|
||||
return dir, true, err
|
||||
}
|
||||
|
||||
// storeSettings are the server's own settings: no TCP address, a socket directory, and no durability.
|
||||
func storeOptions(socket string) string {
|
||||
return "-k " + socket + " -c listen_addresses='' -c fsync=off -c synchronous_commit=off -c full_page_writes=off"
|
||||
}
|
||||
|
||||
// startStore makes and starts the run's postgres in run/pg and answers the address tests are given as
|
||||
// MESH_TEST_POSTGRES, and what stops it. The address names the socket directory percent-encoded, as the controller's
|
||||
// tests cut the URL at its last slash to name their own databases.
|
||||
func (m *Module) startStore(ctx context.Context, run string) (string, func(), error) {
|
||||
data := filepath.Join(run, "pg", "data")
|
||||
socket, short, err := socketDir(run)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
stop := func() {
|
||||
if short {
|
||||
defer os.RemoveAll(socket)
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(context.Background(), storeStopBound)
|
||||
defer cancel()
|
||||
m.Run(sctx, nil, "pg_ctl", "-D", data, "-m", "immediate", "-w", "stop")
|
||||
}
|
||||
if r := m.Run(ctx, nil, "initdb", "-D", data, "-U", "postgres", "--auth=trust", "-E", "UTF8", "--no-locale"); r.Status != 0 || r.Err != "" {
|
||||
return "", nil, fmt.Errorf("initdb: %s", firstLine(r.Err+" "+r.Stderr+" "+r.Stdout))
|
||||
}
|
||||
if r := m.Run(ctx, nil, "pg_ctl", "-D", data, "-l", filepath.Join(run, "pg", "log"), "-w", "-t", "30", "-o", storeOptions(socket), "start"); r.Status != 0 || r.Err != "" {
|
||||
stop() // a server that was half started is stopped
|
||||
return "", nil, fmt.Errorf("pg_ctl start: %s", firstLine(r.Err+" "+r.Stderr+" "+r.Stdout))
|
||||
}
|
||||
return "postgres://postgres@/postgres?host=" + url.QueryEscape(socket) + "&sslmode=disable", stop, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
// The test step (novox/hq ADR 0445; issues 1290 and 1486): a fetched workspace's tests by Go's convention, one level
|
||||
// at a time, each judged by its own bounds. The unit level is `go test` over every package without the `integration`
|
||||
// build tag.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testTimeout is go test's own timeout for a level: a bound on a test that never ends, far above any level's bound.
|
||||
const testTimeout = "10m"
|
||||
|
||||
var packageOK = regexp.MustCompile(`^(\./|[A-Za-z0-9])[A-Za-z0-9._/@~-]*(/\.\.\.)?$`)
|
||||
|
||||
func (m *Module) testTool() StepTool {
|
||||
return StepTool{
|
||||
Name: "build_go_test",
|
||||
Description: "Run the tests of a fetched workspace by Go's convention, one level: the unit level is go test over every " +
|
||||
"package without the integration build tag, within five seconds. Answers a step id at once; the step ends on " +
|
||||
"its key with the level's result: its packages, its times, the tests that failed and the slowest tests. A " +
|
||||
"level that fails is a finished step whose result says passed false.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
|
||||
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
|
||||
"packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "package patterns of each module, ./... when none"},
|
||||
},
|
||||
"required": []string{"workspace", "level"},
|
||||
},
|
||||
Long: true,
|
||||
Run: m.test,
|
||||
}
|
||||
}
|
||||
|
||||
// unitBound is the unit level's bound: UnitBound unless a test lowered it.
|
||||
func (m *Module) unitBound() time.Duration {
|
||||
if m.UnitBound > 0 {
|
||||
return m.UnitBound
|
||||
}
|
||||
return UnitBound
|
||||
}
|
||||
|
||||
func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
workspace, _ := args["workspace"].(string)
|
||||
level, _ := args["level"].(string)
|
||||
packages, err := packagesArg(args["packages"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !workspaceOK.MatchString(workspace) {
|
||||
return nil, fmt.Errorf("%q is not a workspace id", workspace)
|
||||
}
|
||||
tree := filepath.Join(m.Work, workspace, "tree")
|
||||
if _, err := os.Stat(tree); err != nil {
|
||||
return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace)
|
||||
}
|
||||
switch level {
|
||||
case LevelUnit:
|
||||
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}})
|
||||
case LevelIntegration:
|
||||
return m.integration(ctx, workspace, tree, packages)
|
||||
}
|
||||
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
|
||||
}
|
||||
|
||||
func packagesArg(v any) ([]string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("packages is a list of package patterns")
|
||||
}
|
||||
out := []string{}
|
||||
for _, e := range list {
|
||||
s, _ := e.(string)
|
||||
if !packageOK.MatchString(s) || strings.Contains(strings.TrimSuffix(s, "/..."), "..") {
|
||||
return nil, fmt.Errorf("%q is not a package pattern", s)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// levelOpts are what a level asks of runLevel.
|
||||
type levelOpts struct {
|
||||
level string
|
||||
tags []string
|
||||
packages []string
|
||||
bounds Bounds
|
||||
// env is what the level adds to a go command's environment (MESH_TEST_POSTGRES, MESH_TEST_NATS).
|
||||
env []string
|
||||
// setup is the time the level's setup took, which the caller measured; wall says the level's running time is the
|
||||
// wall time of its go test commands, as the integration level's packages run side by side by go test's own
|
||||
// parallelism, where the unit level's is the slowest package's.
|
||||
setup time.Duration
|
||||
wall bool
|
||||
// compose is a state of the level to put in the result (the store and the bus it ran against).
|
||||
store, bus string
|
||||
}
|
||||
|
||||
// runLevel runs `go test -json` in each go.mod directory of the tree and judges the whole against the bounds.
|
||||
func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestResult, error) {
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus}
|
||||
packages := o.packages
|
||||
if len(packages) == 0 {
|
||||
packages = []string{"./..."}
|
||||
}
|
||||
began := m.clock()
|
||||
for _, d := range dirs {
|
||||
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
|
||||
if len(o.tags) > 0 {
|
||||
args = append(args, "-tags", strings.Join(o.tags, ","))
|
||||
}
|
||||
args = append(args, packages...)
|
||||
r := m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...)
|
||||
rd := newReader(res, d)
|
||||
noise := rd.read(r.Stdout)
|
||||
rd.finish()
|
||||
if (r.Status != 0 || r.Err != "") && len(rd.pkgs) == 0 {
|
||||
// go test said nothing a test said: it could not run (a go.mod it cannot read, a toolchain it cannot find).
|
||||
return nil, fmt.Errorf("go test in %s: %s", d, firstLine(r.Err+"\n"+strings.Join(noise, "\n")+"\n"+r.Stderr))
|
||||
}
|
||||
}
|
||||
if o.wall {
|
||||
res.Elapsed = m.clock().Sub(began).Seconds()
|
||||
}
|
||||
res.judge(o.bounds)
|
||||
return res, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user