Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1319432e55 | ||
|
|
34b310b9f8 | ||
|
|
33d8b71cf7 | ||
|
|
aa0eaa6c52 | ||
|
|
d5804927b3 | ||
|
|
cdbdb47474 | ||
|
|
db5e6c5b48 | ||
|
|
b9dcf19c3f | ||
|
|
e3a714d5f0 | ||
|
|
08cec8cb1c | ||
|
|
c3564c1acc | ||
|
|
e4cbffbaa9 | ||
|
|
0f159a1662 | ||
|
|
f0beea153e | ||
|
|
7a7a403485 | ||
|
|
02a8837348 | ||
|
|
0fa427dad3 | ||
|
|
37628f4bce | ||
|
|
6cf51173d5 | ||
|
|
ad85a979e3 | ||
|
|
56c68be7fa | ||
|
|
aef2a1a91f | ||
|
|
b2f5ebb27d | ||
|
|
3259765d46 | ||
|
|
4b9464f3ce | ||
|
|
1f7dd702d2 | ||
|
|
dd31d991cd | ||
|
|
a2deae03b7 | ||
|
|
72ef20a4e7 | ||
|
|
778356f10c | ||
|
|
7c97ce0bf8 | ||
|
|
68e1ad80ac | ||
|
|
6e2762d943 | ||
|
|
dc2b0641bf | ||
|
|
b54640e386 | ||
|
|
6358041eca | ||
|
|
657b38ccd1 | ||
|
|
e959cf339b | ||
|
|
cb226c6fef | ||
|
|
278014fd79 | ||
|
|
caae838a2a | ||
|
|
6ee3507af0 | ||
|
|
06ca1b7fb9 | ||
|
|
90ecba4e50 | ||
|
|
5b3fd1008a | ||
|
|
671ab9a3ba | ||
|
|
0a7c58766c | ||
|
|
454c5662dd | ||
|
|
8fba5e831b | ||
|
|
4efac366ce | ||
|
|
38ac738d84 | ||
|
|
92c5e1d9ae | ||
|
|
a01e1ea7fb | ||
|
|
d74c1cb995 | ||
|
|
2d30dc2f1e | ||
|
|
f178aab641 | ||
|
|
b5dbfbb784 | ||
|
|
e9e85365b5 | ||
|
|
710cfbd622 | ||
|
|
940594b074 | ||
|
|
ff849d9f62 | ||
|
|
f0c8958dd2 | ||
|
|
c33b83dce7 | ||
|
|
36259f3406 | ||
|
|
e023f69989 | ||
|
|
324e331184 | ||
|
|
d8d153295a | ||
|
|
bda75011a1 | ||
|
|
9d1f152603 | ||
|
|
c4cd7a1b16 | ||
|
|
7753c27042 | ||
|
|
ef154705b9 | ||
|
|
d7e6074d97 | ||
|
|
800d6624d5 | ||
|
|
0f2cee1bea | ||
|
|
ceb42b0e92 | ||
|
|
e5cfcb316a | ||
|
|
025d62478a | ||
|
|
e6053262e4 | ||
|
|
6993ef003c | ||
|
|
406ee69732 | ||
|
|
8bfa626a8b | ||
|
|
bfd6b7c7ce | ||
|
|
640c6f9b0c | ||
|
|
d369adf245 | ||
|
|
d8f87808b5 |
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/toolchains
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,104 @@
|
||||
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
|
||||
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
|
||||
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
|
||||
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
|
||||
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Owners names, for each toolchain package, the one module that declares it.
|
||||
var Owners = map[string]string{
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
}
|
||||
|
||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||
// the Package it owns.
|
||||
type Finding struct {
|
||||
Module, Package, Owner string
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
declared := map[string]map[string]bool{} // module -> packages
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
declared[m.Module] = map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "package" {
|
||||
continue
|
||||
}
|
||||
declared[m.Module][r.Package] = true
|
||||
if owner, ok := Owners[r.Package]; ok && owner != m.Module {
|
||||
out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner})
|
||||
}
|
||||
}
|
||||
}
|
||||
var pkgs []string
|
||||
for p := range Owners {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
for _, p := range pkgs {
|
||||
if !declared[Owners[p]][p] {
|
||||
out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Missing {
|
||||
fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n",
|
||||
f.Module, f.Package, f.Owner)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func catalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
|
||||
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue declares a toolchain package outside its owner: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||
|
||||
@@ -122,8 +122,8 @@ the login page again. The logout follows a logout link or form on the page; a pa
|
||||
|
||||
## 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),
|
||||
`scan.added`, said by the device process once the PDF is stored, before the file is written (the tool side files it afterwards, from the store, by a link the device signed; the verb's answer adds the file's `path`), on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
||||
`object` (the key of the stored PDF), `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
||||
`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
// Conversion before printing (novox/hq ADR 0468 clause 7, issue 1600). The printer takes PDF and JPEG. A stored
|
||||
// document of a type the module only-office converts (seat document-converter) is turned into a PDF first: the device
|
||||
// process signs a GET link on the document and a PUT link on <sha>.pdf with the binding's key, calls
|
||||
// document-converter.convert, checks that the PDF the converter says it put is the PDF the bucket holds, and prints
|
||||
// that. Any other type is refused, naming the types taken.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// ConvertFollow is how long the device process follows one conversion, in calls of under 25 s each.
|
||||
const ConvertFollow = 3 * time.Minute
|
||||
|
||||
// ConvertTypes are the types document-converter takes (clause 7).
|
||||
var ConvertTypes = []string{"docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf", "txt", "html"}
|
||||
|
||||
// contentTypes are the media types of a fetched document, by the type the converter is asked for.
|
||||
var contentTypes = map[string]string{
|
||||
"text/html": "html", "application/xhtml+xml": "html", "text/plain": "txt", "application/rtf": "rtf", "text/rtf": "rtf",
|
||||
"application/msword": "doc", "application/vnd.ms-excel": "xls", "application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"application/vnd.oasis.opendocument.text": "odt",
|
||||
"application/vnd.oasis.opendocument.spreadsheet": "ods",
|
||||
"application/vnd.oasis.opendocument.presentation": "odp",
|
||||
}
|
||||
|
||||
// the first bytes of an OLE compound file (the legacy Word, Excel and PowerPoint formats), and the stream names that
|
||||
// tell them apart, as UTF-16 (MS-CFB: directory entry names are UTF-16LE).
|
||||
var oleMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
|
||||
func utf16le(s string) []byte {
|
||||
var b []byte
|
||||
for _, r := range s {
|
||||
b = append(b, byte(r), byte(r>>8))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ConvertibleType is the type a document is, among the converter's twelve, told by its bytes and, for a document that
|
||||
// came from a web server, by its Content-Type; "" when it is none of them. A document that is a PDF or a JPEG is
|
||||
// not asked about: the printer takes it as it is.
|
||||
func ConvertibleType(doc []byte, contentType string) string {
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = contentType[:i]
|
||||
}
|
||||
byHeader := contentTypes[strings.ToLower(strings.TrimSpace(contentType))]
|
||||
switch {
|
||||
case bytes.HasPrefix(doc, []byte("PK\x03\x04")):
|
||||
if t := zipType(doc); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
case bytes.HasPrefix(doc, oleMagic):
|
||||
for name, t := range map[string]string{"WordDocument": "doc", "Workbook": "xls", "PowerPoint Document": "ppt"} {
|
||||
if bytes.Contains(doc, utf16le(name)) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case bytes.HasPrefix(doc, []byte(`{\rtf`)):
|
||||
return "rtf"
|
||||
}
|
||||
if byHeader == "html" || looksLikeHTML(doc) {
|
||||
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) {
|
||||
return "html"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) && len(doc) > 0 {
|
||||
return "txt"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeHTML(doc []byte) bool {
|
||||
head := doc
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
s := strings.ToLower(strings.TrimLeft(strings.TrimPrefix(string(head), "\xef\xbb\xbf"), " \t\r\n"))
|
||||
return strings.HasPrefix(s, "<!doctype html") || strings.HasPrefix(s, "<html")
|
||||
}
|
||||
|
||||
// zipType tells the packages apart by their parts: OOXML by its main part (ECMA-376 Part 2), OpenDocument by its
|
||||
// first file `mimetype` (ODF 1.2 Part 3 §3.3).
|
||||
func zipType(doc []byte) string {
|
||||
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
switch f.Name {
|
||||
case "word/document.xml":
|
||||
return "docx"
|
||||
case "xl/workbook.xml":
|
||||
return "xlsx"
|
||||
case "ppt/presentation.xml":
|
||||
return "pptx"
|
||||
case "mimetype":
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
mt, _ := io.ReadAll(io.LimitReader(rc, 256))
|
||||
rc.Close()
|
||||
switch strings.TrimSpace(string(mt)) {
|
||||
case "application/vnd.oasis.opendocument.text":
|
||||
return "odt"
|
||||
case "application/vnd.oasis.opendocument.spreadsheet":
|
||||
return "ods"
|
||||
case "application/vnd.oasis.opendocument.presentation":
|
||||
return "odp"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toPDF turns a stored document of a converter type into a stored PDF and answers the PDF and its key. contentType is
|
||||
// the media type a web server gave, or "".
|
||||
func (w *deviceWorker) toPDF(ctx context.Context, objects Objects, key string, doc []byte, contentType string) ([]byte, string, error) {
|
||||
typ := ConvertibleType(doc, contentType)
|
||||
if typ == "" {
|
||||
return nil, "", fmt.Errorf("the document is neither a PDF nor a JPEG, nor a type the converter takes (%s): nothing is converted or printed",
|
||||
strings.Join(ConvertTypes, ", "))
|
||||
}
|
||||
signer, ok := objects.(Presigner)
|
||||
if !ok {
|
||||
return nil, "", errors.New("the bucket cannot sign a link, so the document cannot be converted")
|
||||
}
|
||||
if w.Convert == nil {
|
||||
return nil, "", errors.New("this process has no way to call the document converter")
|
||||
}
|
||||
sum := sha256Hex(doc)
|
||||
pdfKey := sum + ".pdf"
|
||||
source, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
target, err := signer.Link(http.MethodPut, pdfKey, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := w.Convert(map[string]any{"source": source, "type": typ, "target": target, "sha256": sum})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
|
||||
}
|
||||
// The converter answers within 25 s, the tool runner cutting every call at 30 s; a conversion not done by then is
|
||||
// answered as converting with its ask id, and is read again with convert {ask}, each call under 25 s, for as long
|
||||
// as the device process follows a conversion.
|
||||
follow := w.ConvertFollow
|
||||
if follow <= 0 {
|
||||
follow = ConvertFollow
|
||||
}
|
||||
until := time.Now().Add(follow)
|
||||
var ans struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Error string `json:"error"`
|
||||
State string `json:"state"`
|
||||
Ask string `json:"ask"`
|
||||
}
|
||||
for {
|
||||
ans.State, ans.Ask, ans.SHA256, ans.Error = "", "", "", ""
|
||||
if err := json.Unmarshal(raw, &ans); err != nil {
|
||||
return nil, "", fmt.Errorf("the converter's answer is not JSON: %w", err)
|
||||
}
|
||||
if ans.State != "converting" {
|
||||
break
|
||||
}
|
||||
if ans.Ask == "" || !time.Now().Before(until) {
|
||||
return nil, "", fmt.Errorf("the document (%s) was still converting after %s (ask %s): nothing is printed", typ, follow, ans.Ask)
|
||||
}
|
||||
if raw, err = w.Convert(map[string]any{"ask": ans.Ask}); err != nil {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
|
||||
}
|
||||
}
|
||||
if ans.Error != "" {
|
||||
return nil, "", fmt.Errorf("the document (%s) could not be converted: %s", typ, ans.Error)
|
||||
}
|
||||
pdf, err := objects.Get(ctx, pdfKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("the converter answered, but no PDF is kept as %s: %w", pdfKey, err)
|
||||
}
|
||||
if got := sha256Hex(pdf); !strings.EqualFold(got, ans.SHA256) {
|
||||
return nil, "", fmt.Errorf("the converter says it put a PDF of SHA-256 %s, but the bucket holds one of %s as %s: nothing is printed", ans.SHA256, got, pdfKey)
|
||||
}
|
||||
if _, _, err := SniffFormat(pdf); err != nil {
|
||||
return nil, "", fmt.Errorf("the converter's result %s: %w", pdfKey, err)
|
||||
}
|
||||
return pdf, pdfKey, nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// docx builds a minimal Word package: the parts ECMA-376 Part 2 requires of one ([Content_Types].xml, the package
|
||||
// relationships and the main part word/document.xml). No real Word file is captured; the layout is the standard's.
|
||||
func docx(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, body := range map[string]string{
|
||||
"[Content_Types].xml": `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>`,
|
||||
"_rels/.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`,
|
||||
"word/document.xml": `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>`,
|
||||
} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// converterFake stands for document-converter.convert: it records the call and, as the converter process would, puts a
|
||||
// PDF in the bucket under the key the target link names and answers its size and SHA-256.
|
||||
type converterFake struct {
|
||||
r *printerRig
|
||||
calls []map[string]any
|
||||
pdf []byte
|
||||
answer func(body map[string]any, stored []byte) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func withConverter(r *printerRig, pdf []byte) *converterFake {
|
||||
c := &converterFake{r: r, pdf: pdf}
|
||||
r.worker.Convert = func(body map[string]any) (json.RawMessage, error) {
|
||||
c.calls = append(c.calls, body)
|
||||
if body["target"] != nil { // a start puts the PDF; a read of an ask (convert {ask}) puts nothing
|
||||
target, _ := url.Parse(body["target"].(string))
|
||||
r.objects.Put(context.Background(), strings.TrimPrefix(target.Path, "/"), "application/pdf", c.pdf)
|
||||
}
|
||||
if c.answer != nil {
|
||||
return c.answer(body, c.pdf)
|
||||
}
|
||||
return json.Marshal(map[string]any{"size": len(c.pdf), "sha256": sha256Hex(c.pdf)})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 1600/AC1: a stored .docx is converted: document-converter.convert is called with a GET link on the source and a PUT
|
||||
// link on <sha>.pdf, both signed in the bucket and expiring within ten minutes, and the PDF is printed.
|
||||
func TestAStoredDocxIsConvertedThenPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 51, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
key := sha256Hex(doc)
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
|
||||
pdf := pdfOf(2500)
|
||||
conv := withConverter(r, pdf)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conv.calls) != 1 {
|
||||
t.Fatalf("%d converter calls", len(conv.calls))
|
||||
}
|
||||
call := conv.calls[0]
|
||||
src, _ := url.Parse(call["source"].(string))
|
||||
dst, _ := url.Parse(call["target"].(string))
|
||||
if src.Path != "/"+key || dst.Path != "/"+key+".pdf" || call["type"] != "docx" || call["sha256"] != key {
|
||||
t.Errorf("call = %v", call)
|
||||
}
|
||||
for _, u := range []*url.URL{src, dst} {
|
||||
if secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires")); secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
|
||||
t.Errorf("%s lives %d s", u, secs)
|
||||
}
|
||||
}
|
||||
if strings.Join(r.objects.links, ",") != "GET "+key+" 600,PUT "+key+".pdf 600" {
|
||||
t.Errorf("links signed: %v", r.objects.links)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(51) || m["format"] != "application/pdf" || m["object"] != key+".pdf" || m["converted-from"] != key {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(pdf) {
|
||||
t.Error("the printer was not sent the converted PDF")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC2: a converter answer whose SHA-256 differs from the stored PDF's is refused and nothing is printed.
|
||||
func TestAConverterAnswerOfAnotherHashPrintsNothing(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
|
||||
conv := withConverter(r, pdfOf(900))
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"size": 900, "sha256": sha256Hex([]byte("another pdf"))})
|
||||
}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing is printed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("something was printed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC3: a converter error is answered with its code and meaning.
|
||||
func TestAConverterErrorIsAnsweredWithItsCodeAndMeaning(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
|
||||
conv := withConverter(r, pdfOf(10))
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return nil, errors.New("the document server refused the conversion, error -4: error while downloading the document file to be converted")
|
||||
}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
|
||||
if err == nil || !strings.Contains(err.Error(), "-4: error while downloading the document file to be converted") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
// An error the converter wrote in its answer is the same.
|
||||
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"error": "-3: conversion error"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)}); err == nil || !strings.Contains(err.Error(), "-3: conversion error") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("something was printed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC4: a type that is neither PDF, JPEG nor one of the converter's twelve is refused naming the types taken, and
|
||||
// convert is not called.
|
||||
func TestAnotherTypeIsRefusedNamingTheTypesTaken(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
exe := []byte("MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00")
|
||||
r.objects.Put(t.Context(), sha256Hex(exe), "application/octet-stream", exe)
|
||||
conv := withConverter(r, pdfOf(10))
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(exe)})
|
||||
if err == nil || !strings.Contains(err.Error(), "docx, doc, xlsx, xls, pptx, ppt, odt, ods, odp, rtf, txt, html") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(conv.calls) != 0 || fake.body != nil {
|
||||
t.Errorf("%d converter calls, printed %v", len(conv.calls), fake.body != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 1600: each of the twelve types is told by its bytes (and a web page also by its Content-Type).
|
||||
func TestTheTypesAreToldByTheirBytes(t *testing.T) {
|
||||
ole := func(stream string) []byte {
|
||||
return append(append([]byte{}, oleMagic...), append(make([]byte, 64), utf16le(stream)...)...)
|
||||
}
|
||||
odf := func(mime string) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create("mimetype")
|
||||
w.Write([]byte(mime))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
ooxml := func(part string) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, _ := zw.Create(part)
|
||||
w.Write([]byte("<x/>"))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
for want, c := range map[string]struct {
|
||||
doc []byte
|
||||
ct string
|
||||
}{
|
||||
"docx": {docx(t), ""}, "xlsx": {ooxml("xl/workbook.xml"), ""}, "pptx": {ooxml("ppt/presentation.xml"), ""},
|
||||
"doc": {ole("WordDocument"), ""}, "xls": {ole("Workbook"), ""}, "ppt": {ole("PowerPoint Document"), ""},
|
||||
"odt": {odf("application/vnd.oasis.opendocument.text"), ""}, "ods": {odf("application/vnd.oasis.opendocument.spreadsheet"), ""},
|
||||
"odp": {odf("application/vnd.oasis.opendocument.presentation"), ""},
|
||||
"rtf": {[]byte(`{\rtf1\ansi hello}`), ""}, "txt": {[]byte("just some words\n"), ""},
|
||||
"html": {[]byte("<!DOCTYPE html><html><body>hi</body></html>"), ""}, "html ": {[]byte("<p>a fragment</p>"), "text/html; charset=utf-8"},
|
||||
} {
|
||||
if got := ConvertibleType(c.doc, c.ct); got != strings.TrimSpace(want) {
|
||||
t.Errorf("%s: told as %q", want, got)
|
||||
}
|
||||
}
|
||||
for name, doc := range map[string][]byte{"empty": {}, "binary": {0, 1, 2, 3}, "a zip of nothing known": ooxml("other.xml"), "ole of nothing known": ole("Other")} {
|
||||
if got := ConvertibleType(doc, ""); got != "" {
|
||||
t.Errorf("%s: told as %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1600/AC5: the device process invokes seat:document-converter.convert.
|
||||
func TestTheDeviceProcessInvokesTheConverter(t *testing.T) {
|
||||
for _, r := range readManifest(t).Resources {
|
||||
if r["id"] == "device" {
|
||||
inv, _ := r["bus"].(map[string]any)["invokes"].([]any)
|
||||
if len(inv) != 1 || inv[0] != "seat:document-converter.convert" {
|
||||
t.Errorf("invokes = %v", inv)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no device process")
|
||||
}
|
||||
|
||||
// 1600 (review round 1): a conversion answered as converting is followed with convert {ask}, each call its own, until
|
||||
// it is done; one still converting after the device process's bound is not printed, and the process.CallTimeout of the
|
||||
// SDK is not touched.
|
||||
func TestAConversionAnsweredAsConvertingIsFollowedWithItsAsk(t *testing.T) {
|
||||
fake := newIPPFake(t, 61, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := docx(t)
|
||||
key := sha256Hex(doc)
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
|
||||
pdf := pdfOf(700)
|
||||
conv := withConverter(r, pdf)
|
||||
rounds := 0
|
||||
conv.answer = func(body map[string]any, stored []byte) (json.RawMessage, error) {
|
||||
if body["ask"] != nil {
|
||||
rounds++
|
||||
if rounds < 3 {
|
||||
return json.RawMessage(`{"state": "converting", "ask": "` + body["ask"].(string) + `"}`), nil
|
||||
}
|
||||
return json.Marshal(map[string]any{"size": len(stored), "sha256": sha256Hex(stored)})
|
||||
}
|
||||
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conv.calls) != 4 || conv.calls[1]["ask"] != "a1b2c3a1b2c3a1b2c3a1b2c3" || conv.calls[1]["source"] != nil {
|
||||
t.Errorf("calls = %v", conv.calls)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(pdf) {
|
||||
t.Error("the converted PDF was not printed")
|
||||
}
|
||||
|
||||
// Always converting: given up at the bound, nothing printed.
|
||||
fake.body = nil
|
||||
r.worker.ConvertFollow = 30 * time.Millisecond
|
||||
conv.answer = func(body map[string]any, _ []byte) (json.RawMessage, error) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "still converting") || fake.body != nil {
|
||||
t.Errorf("error = %v, printed %v", err, fake.body != nil)
|
||||
}
|
||||
src, _ := os.ReadFile("deviceproc.go")
|
||||
if strings.Contains(string(src), "CallTimeout") {
|
||||
t.Error("deviceproc.go sets the SDK's call timeout")
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
@@ -66,6 +69,12 @@ type deviceWorker struct {
|
||||
Node string
|
||||
// Capture scans; nil is the device's own (a test puts a stand-in).
|
||||
Capture func(ctx context.Context, req ScanRequest) ([]Capture, error)
|
||||
// Convert calls the seat document-converter's convert verb as this process's own account (clause 7); nil: none.
|
||||
Convert func(body map[string]any) (json.RawMessage, error)
|
||||
// ConvertFollow is how long a conversion is followed; zero is ConvertFollow (a test makes it shorter).
|
||||
ConvertFollow time.Duration
|
||||
// Fetcher is the guarded fetch of a web link (fetch.go); it reads its facts when it is first needed.
|
||||
Fetcher func() (*Fetcher, 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
|
||||
@@ -247,10 +256,14 @@ func (w *deviceWorker) keep(ctx context.Context, objects Objects, set Settings,
|
||||
|
||||
// keepOne keeps one captured PDF and announces it.
|
||||
func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Settings, c Capture) (map[string]any, error) {
|
||||
// A scan the tool could not take is not kept nor announced: an announced scan is always filed (issue 1539).
|
||||
if most := MostAnswerParts * AnswerPartSize; len(c.PDF) > most {
|
||||
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d the tool can fetch (%d parts of %d): it is not kept; scan with a lower resolution or fewer sheets",
|
||||
len(c.PDF), most, MostAnswerParts, AnswerPartSize)
|
||||
// A scan over a document's limit is not kept nor announced: an announced scan is always filed (issue 1539).
|
||||
if len(c.PDF) > int(objectlink.MaxDocument) {
|
||||
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d a document may be (novox/hq ADR 0468 §3): it is not kept; scan with a lower resolution or fewer sheets",
|
||||
len(c.PDF), objectlink.MaxDocument)
|
||||
}
|
||||
signer, ok := objects.(Presigner)
|
||||
if !ok {
|
||||
return nil, errors.New("the bucket cannot sign a link: the scan is not kept")
|
||||
}
|
||||
body := c.Body(w.Node, set.ColorMode)
|
||||
key := body["object"].(string)
|
||||
@@ -260,7 +273,13 @@ func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Setting
|
||||
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))}
|
||||
// The answer carries a link to read the stored PDF, signed with the binding's key (novox/hq ADR 0468 §4): the tool
|
||||
// side reads the scan by it and files it; no byte of the document is in the answer.
|
||||
link, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but a link to it could not be signed: %w", key, err)
|
||||
}
|
||||
answer := map[string]any{"key": key, "size": len(c.PDF), "link": link, "link-seconds": int(objectlink.MaxExpires / time.Second)}
|
||||
for k, v := range body {
|
||||
answer[k] = v
|
||||
}
|
||||
@@ -370,7 +389,20 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
|
||||
return nil, err
|
||||
}
|
||||
key := ask.Object
|
||||
if key == "" {
|
||||
var doc []byte
|
||||
contentType := ""
|
||||
switch {
|
||||
case ask.URL != "":
|
||||
fetched, err := w.fetch(ctx, ask.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The fetched document is kept once under its SHA-256, as a scan is, and printed as that object.
|
||||
doc, key, contentType = fetched.Body, sha256Hex(fetched.Body), fetched.ContentType
|
||||
if err := Store(ctx, objects, key, fetched.ContentType, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case key == "":
|
||||
for _, ext := range []string{"pdf", "jpg"} {
|
||||
k := "uploads/" + ask.Upload + "." + ext
|
||||
if has, err := objects.Has(ctx, k); err != nil {
|
||||
@@ -383,21 +415,55 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
|
||||
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
|
||||
fallthrough
|
||||
default:
|
||||
var err error
|
||||
if doc, err = objects.Get(ctx, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
format, _, err := SniffFormat(doc)
|
||||
var convertedFrom string
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Not a PDF or a JPEG: a type the converter takes becomes a PDF first (clause 7), any other is refused.
|
||||
pdf, pdfKey, cerr := w.toPDF(ctx, objects, key, doc, contentType)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
doc, convertedFrom, key = pdf, key, pdfKey
|
||||
format = "application/pdf"
|
||||
}
|
||||
// The printer is asked what it takes before the job, and a format it does not list is refused with the list
|
||||
// (novox/hq ADR 0468 clause 5): a printer that cannot be asked prints nothing.
|
||||
listed, err := FormatsOf(ctx, set.Printer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's document formats could not be read, so nothing is sent: %w", err)
|
||||
}
|
||||
if !slices.Contains(listed, format) {
|
||||
return nil, fmt.Errorf("the printer does not take %s: its document-format-supported lists %s", format, strings.Join(listed, ", "))
|
||||
}
|
||||
res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
out := 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}
|
||||
if convertedFrom != "" {
|
||||
out["converted-from"] = convertedFrom
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetch gets a web link under the rules of fetch.go.
|
||||
func (w *deviceWorker) fetch(ctx context.Context, link string) (*Fetched, error) {
|
||||
if w.Fetcher == nil {
|
||||
return nil, errors.New("this process has no way to fetch a web link")
|
||||
}
|
||||
f, err := w.Fetcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Fetch(ctx, link)
|
||||
}
|
||||
|
||||
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
|
||||
@@ -497,7 +563,7 @@ func nonNilForms(f []Form) []Form {
|
||||
// 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")
|
||||
store, fetchFile := os.Getenv("BROTHER_STORE"), os.Getenv("BROTHER_FETCH")
|
||||
if brokerFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file")
|
||||
return 2
|
||||
@@ -519,6 +585,13 @@ func device() int {
|
||||
}
|
||||
return NewS3(facts), nil
|
||||
},
|
||||
Fetcher: func() (*Fetcher, error) {
|
||||
r, err := ReadFetchFacts(fetchFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewFetcher(r), nil
|
||||
},
|
||||
Now: time.Now,
|
||||
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
|
||||
}
|
||||
@@ -541,6 +614,9 @@ func runDevice(ctx context.Context, account process.Account, w *deviceWorker) er
|
||||
}
|
||||
defer conn.Close()
|
||||
w.Answers = conn.State(AnswersState)
|
||||
w.Convert = func(body map[string]any) (json.RawMessage, error) {
|
||||
return conn.Call("seat:document-converter.convert", body)
|
||||
}
|
||||
if w.Node == "" {
|
||||
w.Node = account.Node
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed.
|
||||
// 1453/AC10, 1601: a scan the size of several answer parts is read whole from the store by scanner.scan, checked and filed.
|
||||
func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
@@ -266,15 +266,41 @@ func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
|
||||
// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state.
|
||||
type ippFake struct {
|
||||
url string
|
||||
body []byte
|
||||
body []byte // the Print-Job request
|
||||
status int
|
||||
// formats is document-format-supported, as the printer lists it; asked counts the Get-Printer-Attributes requests.
|
||||
formats []string
|
||||
asked int
|
||||
// delay is how long a Print-Job takes to be answered.
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
|
||||
t.Helper()
|
||||
f := &ippFake{}
|
||||
f := &ippFake{formats: []string{"application/octet-stream", "application/pdf", "image/jpeg"}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.body, _ = io.ReadAll(r.Body)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if binary.BigEndian.Uint16(body[2:4]) == uint16(ippGetPrinterAttributes) {
|
||||
// Get-Printer-Attributes (RFC 8011 §4.2.5): the formats are one attribute of several values, a value
|
||||
// after the first carrying an empty name (§4.1.4), each of the mimeMediaType syntax.
|
||||
f.asked++
|
||||
ans := []byte{0x02, 0x00, 0x00, 0x00, 0, 0, 0, 1, ippOperation}
|
||||
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
|
||||
ans = append(ans, 0x04) // printer-attributes-tag, RFC 8010 §3.5.1
|
||||
for i, format := range f.formats {
|
||||
name := "document-format-supported"
|
||||
if i > 0 {
|
||||
name = ""
|
||||
}
|
||||
ans = appendAttr(ans, ippMimeType, name, format)
|
||||
}
|
||||
ans = append(ans, ippEnd)
|
||||
w.Header().Set("Content-Type", "application/ipp")
|
||||
w.Write(ans)
|
||||
return
|
||||
}
|
||||
f.body = body
|
||||
time.Sleep(f.delay)
|
||||
ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation}
|
||||
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
|
||||
ans = append(ans, ippJob)
|
||||
@@ -337,15 +363,14 @@ func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id.
|
||||
func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
|
||||
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; a document stored under its
|
||||
// SHA-256 (as node-files.upload keeps one) is printed by its key.
|
||||
func TestPrinterPrintDefaultsAndFormatsAndStoredDocuments(t *testing.T) {
|
||||
fake := newIPPFake(t, 8, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...)
|
||||
if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"})
|
||||
r.objects.Put(t.Context(), sha256Hex(jpg), "image/jpeg", jpg)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(jpg)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -357,8 +382,8 @@ func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
|
||||
strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 {
|
||||
t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent))
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") {
|
||||
t.Errorf("an upload never made: error = %v", err)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex([]byte("never stored"))}); err == nil {
|
||||
t.Errorf("an object never stored was printed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,9 +417,9 @@ func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
|
||||
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.
|
||||
// A kept object that is no document the printer takes, nor one the converter takes (1600), is not sent.
|
||||
fake.status, fake.body = 0, nil
|
||||
r.objects.Put(t.Context(), key, "text/plain", []byte("hello"))
|
||||
r.objects.Put(t.Context(), key, "application/octet-stream", []byte{0x00, 0x01, 0xFE, 0xFF, 0x02})
|
||||
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))
|
||||
}
|
||||
@@ -547,83 +572,3 @@ func home(t *testing.T) string {
|
||||
t.Setenv("HOME", h)
|
||||
return h
|
||||
}
|
||||
|
||||
// 1551: printer.print with a path reads the file, uploads it in parts and prints it in one call; a document of
|
||||
// several parts arrives whole, and the answer has the job's id and state.
|
||||
func TestPrinterPrintTakesAPathAndUploadsAndPrintsInOneCall(t *testing.T) {
|
||||
fake := newIPPFake(t, 31, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(2*PartSize + 1234)
|
||||
file := filepath.Join(home(t), "letter.bin") // the name says nothing: the bytes are read
|
||||
if err := os.WriteFile(file, doc, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": file, "copies": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(31) || m["job-state"] != "processing" || m["format"] != "application/pdf" || m["bytes"] != len(doc) {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
attrs, sent := fake.request(t)
|
||||
if !bytes.Equal(sent, doc) || attrs.Attributes["copies"][0].Integer != 2 {
|
||||
t.Errorf("sent %d bytes (want %d), attributes %v", len(sent), len(doc), attrs.Attributes)
|
||||
}
|
||||
ops := r.bus.ops()
|
||||
if len(ops) != 4 || ops[0] != OpUpload || ops[2] != OpUpload || ops[3] != OpPrint {
|
||||
t.Errorf("asks = %v, want three upload parts and a print", ops)
|
||||
}
|
||||
}
|
||||
|
||||
// 1551: only a regular file under the tool account's home folder is read, after symbolic links are resolved; every
|
||||
// other path is refused in one fixed way that says nothing of whether it exists or how large it is; a file in the
|
||||
// home folder that is no PDF or JPEG by its bytes is refused by name of the type.
|
||||
func TestPrinterPrintReadsOnlyAFileUnderTheHomeFolder(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
h := home(t)
|
||||
outside := t.TempDir()
|
||||
secret := filepath.Join(outside, "secret.pdf")
|
||||
os.WriteFile(secret, pdfOf(3000), 0o600)
|
||||
link := filepath.Join(h, "link.pdf")
|
||||
os.Symlink(secret, link)
|
||||
dirLink := filepath.Join(h, "dirlink")
|
||||
os.Symlink(outside, dirLink)
|
||||
os.Mkdir(filepath.Join(h, "folder"), 0o700)
|
||||
for name, path := range map[string]string{
|
||||
"outside home": secret,
|
||||
"link out of home": link,
|
||||
"folder link out": filepath.Join(dirLink, "secret.pdf"),
|
||||
"dot-dot out of home": filepath.Join(h, "..", filepath.Base(outside), "secret.pdf"),
|
||||
"missing in home": filepath.Join(h, "none.pdf"),
|
||||
"missing outside home": filepath.Join(outside, "none.pdf"),
|
||||
"folder": filepath.Join(h, "folder"),
|
||||
"the home folder itself": h,
|
||||
"relative": "secret.pdf",
|
||||
"proc": "/proc/self/environ",
|
||||
} {
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": path})
|
||||
if err == nil || err.Error() != PathRefused {
|
||||
t.Errorf("%s: error = %v, want exactly %q", name, err, PathRefused)
|
||||
}
|
||||
}
|
||||
text := filepath.Join(h, "fake.pdf")
|
||||
os.WriteFile(text, []byte("just text, named .pdf"), 0o600)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": text}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
|
||||
t.Errorf("text in home: error = %v", err)
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": text, "object": sha256Hex([]byte("x")) + ".pdf"}); err == nil {
|
||||
t.Error("path with object accepted")
|
||||
}
|
||||
if r.bus.asks() != 0 || fake.body != nil {
|
||||
t.Errorf("%d asks queued, printer sent %d bytes", r.bus.asks(), len(fake.body))
|
||||
}
|
||||
// A symbolic link inside the home folder to a file inside it is fine.
|
||||
inside := filepath.Join(h, "real.pdf")
|
||||
os.WriteFile(inside, pdfOf(3000), 0o600)
|
||||
os.Symlink(inside, filepath.Join(h, "ok.pdf"))
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"path": filepath.Join(h, "ok.pdf")}); err != nil {
|
||||
t.Errorf("a link within home: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
// A web link the device process fetches (novox/hq ADR 0468 clause 6, issue 1599). The process holds the bucket's key and
|
||||
// the printer's password and sits inside the mesh, so a URL from any caller would otherwise reach the store, the
|
||||
// printer's admin pages or the controller. Only http and https are taken. The fetch takes at most 60 s, 100 MiB and 5
|
||||
// redirects. Each host is resolved once, every address it gives is checked, and the connection is made to an address
|
||||
// that was checked, never to a second lookup's. Loopback, private (RFC 1918, RFC 4193), link-local, shared (RFC 6598),
|
||||
// unspecified, multicast and the mesh's own tunnel range are refused, after every redirect too.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
const (
|
||||
// FetchTime, FetchMost and FetchRedirects are the fetch's bounds (ADR 0468 clause 6).
|
||||
FetchTime = 60 * time.Second
|
||||
FetchMost = objectlink.MaxDocument
|
||||
FetchRedirects = 5
|
||||
// MostURL is the longest address printer.print takes.
|
||||
MostURL = 2048
|
||||
)
|
||||
|
||||
// FetchFacts is the file the mesh writes for the fetch (files/fetch.json): the tunnel's range, which a module cannot
|
||||
// know (mesh-sdk go/catalogue/machine_into_files.go, the fact `mesh-range`).
|
||||
type FetchFacts struct {
|
||||
MeshRange string `json:"mesh-range"`
|
||||
}
|
||||
|
||||
// ReadFetchFacts reads the fetch's facts; a file that names no range is refused, since a fetch that does not know the
|
||||
// mesh's range could reach it.
|
||||
func ReadFetchFacts(path string) (netip.Prefix, error) {
|
||||
if path == "" {
|
||||
return netip.Prefix{}, errors.New("the module was started without the fetch's facts (BROTHER_FETCH)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts cannot be read: %w", err)
|
||||
}
|
||||
var f FetchFacts
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts are not the module's: %v", err)
|
||||
}
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(f.MeshRange))
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the fetch's facts name no mesh range (%q): %v", f.MeshRange, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CheckURLScheme is what the tool side can say of an address without looking at the network: it is http or https.
|
||||
func CheckURLScheme(raw string) error {
|
||||
if len(raw) > MostURL {
|
||||
return fmt.Errorf("url is at most %d characters", MostURL)
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("url %q is not an address", raw)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("url %q has the scheme %q: only http and https are fetched (novox/hq ADR 0468 clause 6)", raw, u.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var refusedRanges = []struct {
|
||||
prefix netip.Prefix
|
||||
class string
|
||||
}{
|
||||
{netip.MustParsePrefix("0.0.0.0/8"), "an unspecified address"},
|
||||
{netip.MustParsePrefix("10.0.0.0/8"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("100.64.0.0/10"), "a shared address (RFC 6598)"},
|
||||
{netip.MustParsePrefix("127.0.0.0/8"), "a loopback address"},
|
||||
{netip.MustParsePrefix("169.254.0.0/16"), "a link-local address"},
|
||||
{netip.MustParsePrefix("172.16.0.0/12"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("192.168.0.0/16"), "a private address (RFC 1918)"},
|
||||
{netip.MustParsePrefix("224.0.0.0/4"), "a multicast address"},
|
||||
{netip.MustParsePrefix("::/128"), "an unspecified address"},
|
||||
{netip.MustParsePrefix("::1/128"), "a loopback address"},
|
||||
{netip.MustParsePrefix("fc00::/7"), "a private address (RFC 4193)"},
|
||||
{netip.MustParsePrefix("fe80::/10"), "a link-local address"},
|
||||
{netip.MustParsePrefix("ff00::/8"), "a multicast address"},
|
||||
}
|
||||
|
||||
// Refusal says why an address may not be fetched from, or "" when it may.
|
||||
func (f *Fetcher) Refusal(a netip.Addr) string {
|
||||
a = a.Unmap()
|
||||
for _, r := range refusedRanges {
|
||||
if r.prefix.Contains(a) {
|
||||
return r.class
|
||||
}
|
||||
}
|
||||
if f.MeshRange.IsValid() && f.MeshRange.Contains(a) {
|
||||
return "an address of the mesh's own network"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fetcher fetches a web link under the rules above. Resolve and Dial are what a test replaces.
|
||||
type Fetcher struct {
|
||||
MeshRange netip.Prefix
|
||||
Resolve func(ctx context.Context, host string) ([]netip.Addr, error)
|
||||
Dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
Timeout time.Duration
|
||||
Most int64
|
||||
Redirects int
|
||||
}
|
||||
|
||||
// NewFetcher is a fetcher with the real resolver and dialer and the bounds of the ADR.
|
||||
func NewFetcher(meshRange netip.Prefix) *Fetcher {
|
||||
d := &net.Dialer{Timeout: 15 * time.Second}
|
||||
return &Fetcher{MeshRange: meshRange, Dial: d.DialContext, Timeout: FetchTime, Most: FetchMost, Redirects: FetchRedirects,
|
||||
Resolve: func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
}}
|
||||
}
|
||||
|
||||
// Fetched is a document fetched: its bytes and the media type the server gave.
|
||||
type Fetched struct {
|
||||
Body []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Fetch gets the document at the address. Every refusal names why and happens before a connection to the refused
|
||||
// address is made; a body over the limit or a fetch over the time stores nothing, since nothing is returned.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, raw string) (*Fetched, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.Timeout)
|
||||
defer cancel()
|
||||
current := raw
|
||||
for hop := 0; ; hop++ {
|
||||
if err := CheckURLScheme(current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, _ := url.Parse(current)
|
||||
addr, err := f.checked(ctx, u.Hostname())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", current, err)
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
|
||||
}
|
||||
target := net.JoinHostPort(addr.String(), port)
|
||||
tr := &http.Transport{
|
||||
Proxy: nil, // never the environment's proxy: the address checked is the address dialled
|
||||
DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return f.Dial(ctx, network, target)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
|
||||
if err != nil {
|
||||
tr.CloseIdleConnections()
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tr.CloseIdleConnections()
|
||||
return nil, fmt.Errorf("%s could not be fetched: %w", current, err)
|
||||
}
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "" {
|
||||
loc, lerr := u.Parse(resp.Header.Get("Location"))
|
||||
resp.Body.Close()
|
||||
tr.CloseIdleConnections()
|
||||
if lerr != nil {
|
||||
return nil, fmt.Errorf("%s redirects to an address that is not one: %v", current, lerr)
|
||||
}
|
||||
if hop >= f.Redirects {
|
||||
return nil, fmt.Errorf("%s is redirected more than %d times", raw, f.Redirects)
|
||||
}
|
||||
current = loc.String()
|
||||
continue
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", current, resp.Status)
|
||||
}
|
||||
if resp.ContentLength > f.Most {
|
||||
return nil, fmt.Errorf("%s is %d bytes, over the %d a document may be (novox/hq ADR 0468 clause 6)", current, resp.ContentLength, f.Most)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, f.Most+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s could not be read within %s: %w", current, f.Timeout, err)
|
||||
}
|
||||
if int64(len(body)) > f.Most {
|
||||
return nil, fmt.Errorf("%s is over the %d bytes a document may be (novox/hq ADR 0468 clause 6)", current, f.Most)
|
||||
}
|
||||
return &Fetched{Body: body, ContentType: resp.Header.Get("Content-Type")}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// checked resolves the host once, refuses it when any address it gives is refused, and answers the address to dial.
|
||||
func (f *Fetcher) checked(ctx context.Context, host string) (netip.Addr, error) {
|
||||
var addrs []netip.Addr
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
addrs = []netip.Addr{ip}
|
||||
} else {
|
||||
found, err := f.Resolve(ctx, host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("the host %s cannot be resolved: %w", host, err)
|
||||
}
|
||||
addrs = found
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return netip.Addr{}, fmt.Errorf("the host %s resolves to no address", host)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if class := f.Refusal(a); class != "" {
|
||||
return netip.Addr{}, fmt.Errorf("refused: %s is %s, and the device process fetches nothing inside the mesh or the machine (novox/hq ADR 0468 clause 6)", a, class)
|
||||
}
|
||||
}
|
||||
return addrs[0].Unmap(), nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked,
|
||||
// after the fetcher has checked the address it was about to dial and told the dial what that was.
|
||||
type fetchRig struct {
|
||||
f *Fetcher
|
||||
mu sync.Mutex
|
||||
dialed []string
|
||||
lookups map[string]int
|
||||
names map[string][]netip.Addr
|
||||
// after is what a host answers from its second lookup on, when it answers differently (a rebinding).
|
||||
after map[string][]netip.Addr
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig {
|
||||
t.Helper()
|
||||
r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}}
|
||||
r.srv = httptest.NewServer(h)
|
||||
t.Cleanup(r.srv.Close)
|
||||
r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects,
|
||||
Resolve: func(_ context.Context, host string) ([]netip.Addr, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.lookups[host]++
|
||||
a, ok := r.names[host]
|
||||
if !ok {
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 {
|
||||
return again, nil
|
||||
}
|
||||
return a, nil
|
||||
},
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
r.mu.Lock()
|
||||
r.dialed = append(r.dialed, address)
|
||||
r.mu.Unlock()
|
||||
return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String())
|
||||
}}
|
||||
return r
|
||||
}
|
||||
|
||||
func addrs(s ...string) []netip.Addr {
|
||||
var out []netip.Addr
|
||||
for _, a := range s {
|
||||
out = append(out, netip.MustParseAddr(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1599/AC1: a url with a scheme other than http or https is refused by name.
|
||||
func TestAURLOfAnotherSchemeIsRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {})
|
||||
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} {
|
||||
_, err := r.f.Fetch(t.Context(), u)
|
||||
if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) {
|
||||
t.Errorf("%s: error = %v", u, err)
|
||||
}
|
||||
if err := CheckURLScheme(u); err == nil {
|
||||
t.Errorf("%s: the tool side took it", u)
|
||||
}
|
||||
}
|
||||
if len(r.dialed) != 0 {
|
||||
t.Errorf("dialled %v", r.dialed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address,
|
||||
// or one in the mesh's range, is refused naming the class, and no connection is made.
|
||||
func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") })
|
||||
cases := map[string]string{
|
||||
"127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)",
|
||||
"172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local",
|
||||
"100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast",
|
||||
"10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback",
|
||||
}
|
||||
for ip, class := range cases {
|
||||
host := ip
|
||||
if strings.Contains(ip, ":") {
|
||||
host = "[" + ip + "]"
|
||||
}
|
||||
_, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf")
|
||||
if err == nil || !strings.Contains(err.Error(), class) {
|
||||
t.Errorf("%s: error = %v, want the class %q", ip, err, class)
|
||||
}
|
||||
// The same address behind a name.
|
||||
r.names["inside.test"] = addrs("203.0.113.9", ip)
|
||||
if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) {
|
||||
t.Errorf("inside.test -> %s: error = %v", ip, err)
|
||||
}
|
||||
}
|
||||
// The mesh's range from fetch.json, when it is not a private range.
|
||||
r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24")
|
||||
if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") {
|
||||
t.Errorf("the mesh range: error = %v", err)
|
||||
}
|
||||
if len(r.dialed) != 0 {
|
||||
t.Errorf("a connection was made to %v", r.dialed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed.
|
||||
func TestRedirectsAreCheckedAndBounded(t *testing.T) {
|
||||
hops := 0
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/to-loopback":
|
||||
http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound)
|
||||
case "/to-name":
|
||||
http.Redirect(w, req, "http://inside.test/x", http.StatusFound)
|
||||
case "/loop":
|
||||
hops++
|
||||
http.Redirect(w, req, "/loop", http.StatusFound)
|
||||
case "/ok":
|
||||
http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently)
|
||||
default:
|
||||
w.Write([]byte("%PDF-1.7 final"))
|
||||
}
|
||||
})
|
||||
r.names["public.test"] = addrs("203.0.113.9")
|
||||
r.names["inside.test"] = addrs("192.168.0.9")
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Errorf("redirect to loopback: error = %v", err)
|
||||
}
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") {
|
||||
t.Errorf("redirect to a name: error = %v", err)
|
||||
}
|
||||
if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") {
|
||||
t.Errorf("a loop: error = %v", err)
|
||||
}
|
||||
if hops != 6 {
|
||||
t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops)
|
||||
}
|
||||
got, err := r.f.Fetch(t.Context(), "http://public.test/ok")
|
||||
if err != nil || string(got.Body) != "%PDF-1.7 final" {
|
||||
t.Errorf("a redirect to a public address: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored.
|
||||
func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) {
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/big":
|
||||
w.Write([]byte(strings.Repeat("x", 2000)))
|
||||
case "/chunked":
|
||||
w.(http.Flusher).Flush() // no length is declared
|
||||
w.Write([]byte(strings.Repeat("x", 2000)))
|
||||
case "/slow":
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.Write([]byte("late"))
|
||||
}
|
||||
})
|
||||
r.names["public.test"] = addrs("203.0.113.9")
|
||||
r.f.Most = 1000
|
||||
for _, p := range []string{"/big", "/chunked"} {
|
||||
if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") {
|
||||
t.Errorf("%s: %v, %v", p, got, err)
|
||||
}
|
||||
}
|
||||
r.f.Timeout = 100 * time.Millisecond
|
||||
if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil {
|
||||
t.Errorf("a slow fetch: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup
|
||||
// is not dialled again, and each host is looked up once.
|
||||
func TestTheCheckedAddressIsTheOneDialled(t *testing.T) {
|
||||
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) })
|
||||
r.names["rebind.test"] = addrs("203.0.113.9")
|
||||
r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback
|
||||
if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" {
|
||||
t.Errorf("dialled %v, want the checked address and the url's port", r.dialed)
|
||||
}
|
||||
if r.lookups["rebind.test"] != 1 {
|
||||
t.Errorf("the host was looked up %d times", r.lookups["rebind.test"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object.
|
||||
func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 41, 5)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(4000)
|
||||
web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Write(doc)
|
||||
})
|
||||
web.names["public.test"] = addrs("203.0.113.9")
|
||||
r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil }
|
||||
got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
key := sha256Hex(doc)
|
||||
if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) {
|
||||
t.Errorf("stored under the hash: %v", err)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||
t.Error("the printer was not sent the fetched document")
|
||||
}
|
||||
queued := r.bus.published[0]
|
||||
if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" {
|
||||
t.Errorf("queued %+v", queued)
|
||||
}
|
||||
if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil {
|
||||
t.Errorf("https refused: %v", err)
|
||||
}
|
||||
// A refusal inside the mesh is answered, and nothing is stored nor printed.
|
||||
fake.body = nil
|
||||
before := len(r.objects.keys())
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||
t.Errorf("a loopback address: error = %v", err)
|
||||
}
|
||||
if len(r.objects.keys()) != before || fake.body != nil {
|
||||
t.Error("something was stored or printed for a refused address")
|
||||
}
|
||||
}
|
||||
|
||||
// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device
|
||||
// process reads them.
|
||||
func TestTheFetchFactsAreTheMeshRange(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../files/fetch.json")
|
||||
if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) {
|
||||
t.Fatalf("files/fetch.json = %s, %v", raw, err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "fetch.json")
|
||||
os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600)
|
||||
p, err := ReadFetchFacts(file)
|
||||
if err != nil || p.String() != "10.10.0.0/24" {
|
||||
t.Errorf("prefix %v, %v", p, err)
|
||||
}
|
||||
os.WriteFile(file, []byte(`{}`), 0o600)
|
||||
if _, err := ReadFetchFacts(file); err == nil {
|
||||
t.Error("facts with no range were taken")
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestAnOpenDocumentIsFoundAgainAfterARestartAndItsPagesAreDeletedWhenClosed(
|
||||
docExpiry = 200 * time.Millisecond
|
||||
t.Cleanup(func() { docExpiry = old })
|
||||
s := &Service{Settings: Settings{Scanner: f.base(), Folder: folder, Resolution: 200, ColorMode: "color"}, Now: func() time.Time { return fixedNow },
|
||||
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs()}
|
||||
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs(), Fetch: fetchClient, DocExpiry: docExpiry}
|
||||
if err := s.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -245,6 +245,39 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
|
||||
return ans.State()
|
||||
}
|
||||
|
||||
// formatAttributes is what the format check asks for: the document formats the printer takes (RFC 8011 §5.4.22).
|
||||
var formatAttributes = []string{"document-format-supported"}
|
||||
|
||||
// FormatsOf asks the printer, over IPP, which document formats it lists in document-format-supported.
|
||||
func FormatsOf(ctx context.Context, host string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
|
||||
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), formatAttributes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/ipp")
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's IPP service does not answer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
||||
}
|
||||
ans, err := DecodeIPP(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ans.Status >= 0x0100 {
|
||||
return nil, fmt.Errorf("the printer refused the IPP request with status 0x%04x", ans.Status)
|
||||
}
|
||||
return ans.texts("document-format-supported"), nil
|
||||
}
|
||||
|
||||
// PrintOptions are what a Print-Job asks of the printer.
|
||||
type PrintOptions struct {
|
||||
Format string // document-format, application/pdf or image/jpeg
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// uploadVia is how the tool side asks a machine's node-files for an upload; a test puts its own in its place.
|
||||
var uploadVia = sdkUpload
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "device" {
|
||||
os.Exit(device())
|
||||
@@ -84,7 +87,7 @@ func askFrom(args map[string]any) (Ask, error) {
|
||||
var a Ask
|
||||
var err error
|
||||
for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256,
|
||||
"data": &a.Data, "sides": &a.Sides, "media": &a.Media} {
|
||||
"data": &a.Data, "sides": &a.Sides, "media": &a.Media, "path": &a.Path, "node": &a.Node, "url": &a.URL, "ask": &a.Of} {
|
||||
if *dst, err = strArg(args, name); err != nil {
|
||||
return a, err
|
||||
}
|
||||
@@ -109,7 +112,8 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
}
|
||||
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
|
||||
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll,
|
||||
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs}, nil
|
||||
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs, Upload: uploadVia, Fetch: fetchClient, DocExpiry: docExpiry, DocRetry: docRetry,
|
||||
Bucket: func() (string, error) { return ReadBucket(os.Getenv("BROTHER_BUCKET")) }}, nil
|
||||
}
|
||||
// ask is a tool that reads its arguments, and answers what the service gives.
|
||||
ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) {
|
||||
@@ -147,39 +151,37 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "printer.print",
|
||||
Description: "Print a document on the printer: a file under the home folder of the account the tool runs as, on the machine it runs on (path: reads it, uploads it in parts " +
|
||||
"and prints it, in one call), a scan kept in the bucket (object), or a completed upload (upload; for a module that sends " +
|
||||
"bytes over the bus). Only PDF and JPEG, told by the file's bytes and not its name. The device " +
|
||||
"process sends it as an IPP Print-Job in its own format and answers the job's id and state (with path, the upload id and the size).",
|
||||
Description: "Print a document on the printer, in one call: a key in the bucket (object: a scan, or a document stored there), a file on any " +
|
||||
"machine of the mesh (path with node: that machine's node-files sends it into the bucket, a place it keeps a secret is " +
|
||||
"refused there by name and nothing is printed), or a web link (url). Name exactly one. A Word, spreadsheet or slide " +
|
||||
"document is turned into a PDF first; otherwise only PDF and JPEG, told by the bytes and not the name, and only a format " +
|
||||
"the printer lists in document-format-supported. The device process sends it as an IPP Print-Job and answers the job's " +
|
||||
"id and state. No document crosses the bus. A call answers within 25 s (the tool runner cuts a call at 30 s): a print not done by then goes on and is answered as started with its ask id, and printer.print with only that ask reads it.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"path": str("an absolute path of a PDF or JPEG file under the tool account's home folder (symbolic links are resolved and the real path must lie there too; any other path is refused in one fixed way)"),
|
||||
"object": str("a scan's key, as scan.added names it"),
|
||||
"upload": str("a completed upload's id"),
|
||||
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
|
||||
"path": str("an absolute path of a file on the machine node names"),
|
||||
"node": str("the machine path is on, such as g14"),
|
||||
"url": str("an http or https address of a document or a page"),
|
||||
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
|
||||
"media": str("the paper's size name (default " + DefaultMedia + ")"),
|
||||
}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
path, err := strArg(args, "path")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := askFrom(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Of != "" && len(args) > 1 {
|
||||
return nil, fmt.Errorf("ask is read on its own: give the ask id alone to read an earlier print")
|
||||
}
|
||||
s, err := service()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
return s.PrinterPrint(ctx, a)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
return s.PrintFile(ctx, path, a)
|
||||
return s.PrinterPrint(ctx, a)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Requires []string `json:"requires"`
|
||||
Requires []requirement `json:"requires"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Binds map[string]string `json:"binds"`
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Emits []string `json:"emits"`
|
||||
@@ -28,6 +29,31 @@ type manifest struct {
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
// requirement is an entry of `requires`: a name, or an object with the name and the shares of the bucket.
|
||||
type requirement struct {
|
||||
Name string `json:"name"`
|
||||
Shares []struct {
|
||||
To string `json:"to"`
|
||||
Methods []string `json:"methods"`
|
||||
} `json:"shares"`
|
||||
}
|
||||
|
||||
func (r *requirement) UnmarshalJSON(b []byte) error {
|
||||
if err := json.Unmarshal(b, &r.Name); err == nil {
|
||||
return nil
|
||||
}
|
||||
type plain requirement
|
||||
return json.Unmarshal(b, (*plain)(r))
|
||||
}
|
||||
|
||||
func (m manifest) requireNames() []string {
|
||||
var out []string
|
||||
for _, r := range m.Requires {
|
||||
out = append(out, r.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
@@ -265,6 +291,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
|
||||
"BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"],
|
||||
"BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"],
|
||||
"BROTHER_STORE": "${dir:state}/store.json",
|
||||
"BROTHER_FETCH": "${dir:state}/fetch.json", // 1599: the tunnel's range, which the fetch never reaches
|
||||
}
|
||||
if !reflect.DeepEqual(env, want) {
|
||||
t.Errorf("env = %v, want %v", env, want)
|
||||
@@ -330,8 +357,8 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
|
||||
// written into store.json, a file only the device account can read; the tools' bundle is not told where it is.
|
||||
func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) {
|
||||
t.Errorf("requires = %v", m.Requires)
|
||||
if !reflect.DeepEqual(m.requireNames(), []string{"s3-bucket"}) {
|
||||
t.Errorf("requires = %v", m.requireNames())
|
||||
}
|
||||
if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" {
|
||||
t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets)
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// Objects is the module's bucket.
|
||||
@@ -55,6 +57,13 @@ func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) e
|
||||
return o.Put(ctx, key, mediaType, data)
|
||||
}
|
||||
|
||||
// Presigner is a store that signs a link to one of its objects (novox/hq ADR 0468 §3): the device process's, with the
|
||||
// binding's key, which reaches this module's bucket only.
|
||||
type Presigner interface {
|
||||
// Link is a link for GET or PUT of one key that lives for the given time (at most ten minutes).
|
||||
Link(method, key string, expires time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
|
||||
type StoreFacts struct {
|
||||
Scheme string `json:"scheme"`
|
||||
@@ -114,6 +123,16 @@ func NewS3(f StoreFacts) *S3 {
|
||||
|
||||
func (s *S3) Bucket() string { return s.Name }
|
||||
|
||||
// Link signs a link for one object of the bucket with the binding's key, for the host the binding gives.
|
||||
func (s *S3) Link(method, key string, expires time.Duration) (string, error) {
|
||||
now := time.Now
|
||||
if s.Now != nil {
|
||||
now = s.Now
|
||||
}
|
||||
return objectlink.Presign(objectlink.Request{Method: method, Endpoint: s.Endpoint, Bucket: s.Name, Key: key,
|
||||
AccessKey: s.Access, SecretKey: s.Secret, Region: s.Region, Expires: expires, Now: now()})
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,9 +10,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memObjects is the bucket in memory, for the tests of what the device process keeps.
|
||||
@@ -19,6 +22,10 @@ type memObjects struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
puts int
|
||||
// corrupt, when set, makes a GET of a link answer bytes that are not the kept ones.
|
||||
corrupt bool
|
||||
// links records each link signed: "<method> <key> <seconds>".
|
||||
links []string
|
||||
}
|
||||
|
||||
func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} }
|
||||
@@ -68,6 +75,32 @@ func (m *memObjects) Delete(_ context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Link is a link the bucket in memory serves itself (RoundTrip): the key in the path and the life in the query, as a
|
||||
// presigned link says them (X-Amz-Expires).
|
||||
func (m *memObjects) Link(method, key string, expires time.Duration) (string, error) {
|
||||
m.mu.Lock()
|
||||
m.links = append(m.links, method+" "+key+" "+strconv.Itoa(int(expires/time.Second)))
|
||||
m.mu.Unlock()
|
||||
return "http://" + m.Bucket() + ".objects.test/" + key + "?X-Amz-Expires=" + strconv.Itoa(int(expires/time.Second)), nil
|
||||
}
|
||||
|
||||
// RoundTrip answers a GET of a link made by Link with the object kept under its key.
|
||||
func (m *memObjects) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
m.mu.Lock()
|
||||
b, ok := m.kept[strings.TrimPrefix(r.URL.Path, "/")]
|
||||
corrupt := m.corrupt
|
||||
m.mu.Unlock()
|
||||
if corrupt && ok {
|
||||
b = append(append([]byte(nil), b...), 0)
|
||||
}
|
||||
status := 200
|
||||
if !ok {
|
||||
status = 404
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(bytes.NewReader(b)), ContentLength: int64(len(b)),
|
||||
Header: http.Header{}, Request: r}, nil
|
||||
}
|
||||
|
||||
func (m *memObjects) keys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -36,6 +36,11 @@ const (
|
||||
// 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"
|
||||
// PrintTakeUp is how long a print ask may wait on the queue before the device process takes it up, and PrintFollow
|
||||
// how long the tool side follows it to its end: the device process may fetch and convert before the job. A call
|
||||
// answers within QuickWait whatever these are, and the print goes on (the tool runner cuts every call at 30 s).
|
||||
PrintTakeUp = 3 * time.Minute
|
||||
PrintFollow = 6 * time.Minute
|
||||
// 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
|
||||
@@ -88,6 +93,14 @@ type Ask struct {
|
||||
|
||||
Document string `json:"document,omitempty"` // scan: start, or an open document's id: a glass page into it
|
||||
Finish bool `json:"finish,omitempty"` // scan: close the document named in Document into one PDF
|
||||
|
||||
// Path with Node are what printer.print is asked for a file of a machine: the tool side turns them into an Object
|
||||
// before anything is queued, so the queued ask carries a key and the options and never these (ADR 0468 clause 5).
|
||||
// A URL is queued as given: the device process alone fetches it (clause 6).
|
||||
Path string `json:"-"` // print: a file on the machine Node
|
||||
Of string `json:"-"` // print: the ask id of an earlier print, whose outcome is read
|
||||
Node string `json:"-"` // print: the machine Path is on
|
||||
URL string `json:"url,omitempty"` // print: a web document the device process fetches (issue 1599)
|
||||
}
|
||||
|
||||
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
|
||||
@@ -145,7 +158,10 @@ func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[st
|
||||
// queue puts the ask on the work queue and answers its id, under which the answer is written. The asker waits
|
||||
// startBy for the device process to take the ask up (0 is for as long as it is waited for).
|
||||
func (s *Service) queue(ask Ask, startBy time.Duration) (string, error) {
|
||||
ask.ID, ask.At = newAskID(), s.Now().UTC()
|
||||
if ask.ID == "" {
|
||||
ask.ID = newAskID()
|
||||
}
|
||||
ask.At = s.Now().UTC()
|
||||
if startBy > 0 {
|
||||
ask.Deadline = ask.At.Add(startBy)
|
||||
}
|
||||
@@ -211,9 +227,12 @@ func validPage(page string) bool {
|
||||
|
||||
var (
|
||||
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
|
||||
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
// printKeyPattern is a document the bucket keeps under its SHA-256: a scan's key, or the key node-files.upload
|
||||
// answers for a file sent from a machine (the bare SHA-256).
|
||||
printKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}(\.pdf)?$`)
|
||||
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
// Sides are the values the printer's sides take.
|
||||
@@ -259,14 +278,23 @@ func checkUpload(a Ask) error {
|
||||
|
||||
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
|
||||
func checkPrint(a *Ask) error {
|
||||
if (a.Object == "") == (a.Upload == "") {
|
||||
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
|
||||
if a.Upload != "" {
|
||||
return fmt.Errorf("upload is no longer a source of printer.print: send the file with path and node, or name its key in object")
|
||||
}
|
||||
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
||||
if (a.Path == "") != (a.Node == "") {
|
||||
return fmt.Errorf("path and node go together: path is a file on the machine node names")
|
||||
}
|
||||
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
|
||||
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
||||
sources := 0
|
||||
for _, given := range []bool{a.Object != "", a.Path != "", a.URL != ""} {
|
||||
if given {
|
||||
sources++
|
||||
}
|
||||
}
|
||||
if sources != 1 {
|
||||
return fmt.Errorf("name exactly one of object (a key in the bucket), path with node (a file on that machine) or url, not %s", map[bool]string{true: "none", false: "two or more"}[sources == 0])
|
||||
}
|
||||
if a.Object != "" && !printKeyPattern.MatchString(a.Object) {
|
||||
return fmt.Errorf("object %q is not a stored document's key: 64 hex digits, with .pdf for a scan", a.Object)
|
||||
}
|
||||
if a.Copies == 0 {
|
||||
a.Copies = 1
|
||||
@@ -307,50 +335,42 @@ func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, err
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
}
|
||||
|
||||
// PrintFile prints a PDF or a JPEG that lies on the machine the tool runs on: it reads the file, uploads it in
|
||||
// parts under an upload id of its own and prints that, in one call (issue 1551). The type is read from the
|
||||
// file's first bytes, never its name; any other file is refused before anything is queued. The answer is the
|
||||
// print's, with the upload id and the file's size.
|
||||
func (s *Service) PrintFile(ctx context.Context, path string, a Ask) (map[string]any, error) {
|
||||
if a.Object != "" || a.Upload != "" {
|
||||
return nil, fmt.Errorf("name path, or object, or upload: one of the three")
|
||||
}
|
||||
// The path is checked first, and refused in one fixed way that tells nothing about the file.
|
||||
doc, err := ReadDocument(path, MostParts*PartSize)
|
||||
// sdkUpload asks node's node-files to send a file of that machine into the bucket and answers the key it was
|
||||
// stored under (issue 1597). The form is the seat's verb on one machine, `seat:<seat>.<verb>@<node>`.
|
||||
func sdkUpload(node, path, bucket string) (string, error) {
|
||||
raw, err := stdio.Ask("seat:node-files.upload@"+node, map[string]any{"path": path, "bucket": bucket})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
a.Upload = "p-" + newAskID()
|
||||
if err := checkPrint(&a); err != nil {
|
||||
return nil, err
|
||||
var ans struct {
|
||||
Key string `json:"key"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if s.Settings.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
if err := json.Unmarshal(raw, &ans); err != nil {
|
||||
return "", fmt.Errorf("node-files' answer is not JSON: %w", err)
|
||||
}
|
||||
if _, _, err := SniffFormat(doc); err != nil {
|
||||
return nil, fmt.Errorf("file %s: %w", path, err)
|
||||
if ans.Key == "" && len(ans.Answer) > 0 {
|
||||
_ = json.Unmarshal(ans.Answer, &ans)
|
||||
}
|
||||
sum, n := sha256Hex(doc), partCount(len(doc))
|
||||
for i := 1; i <= n; i++ {
|
||||
part, _, _ := cutPartOf(doc, i, PartSize)
|
||||
up, err := s.PrinterUpload(ctx, Ask{Upload: a.Upload, Part: i, Parts: n, SHA256: sum, Data: base64.StdEncoding.EncodeToString(part)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("part %d of %d of %s could not be uploaded: %w", i, n, path, err)
|
||||
}
|
||||
if i == n && up["complete"] != true {
|
||||
return nil, fmt.Errorf("the upload of %s is not complete after its last part: %v", path, up)
|
||||
}
|
||||
if ans.Key == "" {
|
||||
return "", fmt.Errorf("node-files' answer names no key")
|
||||
}
|
||||
out, err := s.PrinterPrint(ctx, a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is uploaded as %s but could not be printed: %w", path, a.Upload, err)
|
||||
}
|
||||
out["path"], out["upload"], out["bytes"] = path, a.Upload, len(doc)
|
||||
return out, nil
|
||||
return ans.Key, nil
|
||||
}
|
||||
|
||||
// PrinterPrint asks the device process to print a kept scan or a completed upload.
|
||||
// PrinterPrint prints a stored document, a file of a machine, or a web link, in one call. A file of a machine is first
|
||||
// sent into the bucket by that machine's node-files (refused there by name when it is a secret, and then nothing is
|
||||
// printed); the print ask the device process takes carries the key and the options and no bytes.
|
||||
//
|
||||
// The tool runner cuts every call at 30 s, so the call answers within QuickWait, upload included. A print not done by
|
||||
// then goes on in the background and is answered as started, with its ask id; printer.print {ask} reads its outcome.
|
||||
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
|
||||
if a.Of != "" {
|
||||
if a.Object != "" || a.Path != "" || a.Node != "" || a.URL != "" || a.Upload != "" {
|
||||
return nil, fmt.Errorf("ask is read on its own: name exactly one of object, path with node, url, or the ask id of an earlier print")
|
||||
}
|
||||
return s.PrintResult(ctx, a.Of)
|
||||
}
|
||||
a.Op = OpPrint
|
||||
if err := checkPrint(&a); err != nil {
|
||||
return nil, err
|
||||
@@ -358,9 +378,103 @@ func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, erro
|
||||
if s.Settings.Printer == "" {
|
||||
return nil, unsetError("printer")
|
||||
}
|
||||
return s.call(ctx, a, s.AnswerWait)
|
||||
bucket := ""
|
||||
switch {
|
||||
case a.URL != "":
|
||||
if err := CheckURLScheme(a.URL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case a.Path != "":
|
||||
if s.Upload == nil {
|
||||
return nil, fmt.Errorf("this module has no way to ask node-files for an upload")
|
||||
}
|
||||
var err error
|
||||
if bucket, err = s.Bucket(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
id := newAskID()
|
||||
j := s.Jobs.put(id)
|
||||
go func() {
|
||||
j.body, j.err = s.printRun(id, a, bucket)
|
||||
close(j.done)
|
||||
}()
|
||||
return s.printWait(ctx, id, j)
|
||||
}
|
||||
|
||||
// printRun is a print followed to its end, however long the caller waits: the upload, the ask, the device's answer.
|
||||
func (s *Service) printRun(id string, a Ask, bucket string) (map[string]any, error) {
|
||||
from := map[string]any{}
|
||||
if a.Path != "" {
|
||||
key, err := s.Upload(a.Node, a.Path, bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s on %s could not be sent to the bucket, and nothing is printed: %w", a.Path, a.Node, err)
|
||||
}
|
||||
from["path"], from["node"] = a.Path, a.Node
|
||||
a.Object, a.Path, a.Node = key, "", ""
|
||||
}
|
||||
a.ID = id
|
||||
if _, err := s.queue(a, PrintTakeUp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), PrintFollow)
|
||||
defer cancel()
|
||||
out, err := s.await(ctx, id, PrintFollow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range from {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// printWait gives the print's outcome if it comes within QuickWait, and started with the ask id otherwise.
|
||||
func (s *Service) printWait(ctx context.Context, id string, j *job) (map[string]any, error) {
|
||||
quick := s.QuickWait
|
||||
if quick <= 0 {
|
||||
quick = QuickWait
|
||||
}
|
||||
select {
|
||||
case <-j.done:
|
||||
return j.body, j.err
|
||||
case <-time.After(quick):
|
||||
return map[string]any{"state": "started", "ask": id,
|
||||
"note": "the print goes on; read its outcome with printer.print {ask}"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("the caller went away; the print %s goes on: %w", id, ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// PrintResult is the outcome of the print with this ask id: the device's answer when it is done (a failure is the
|
||||
// error), and started, still printing, while it is not. A print this process followed is read from it, one it did not
|
||||
// (it was restarted) from the device's answers, which are kept for 10 minutes.
|
||||
func (s *Service) PrintResult(ctx context.Context, id string) (map[string]any, error) {
|
||||
if !askIDPattern.MatchString(id) {
|
||||
return nil, fmt.Errorf("ask %q is not an ask id: the 24 hex digits printer.print answered", id)
|
||||
}
|
||||
if j := s.Jobs.get(id); j != nil {
|
||||
return s.printWait(ctx, id, j)
|
||||
}
|
||||
raw, err := s.Asks.Answer(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
|
||||
}
|
||||
if raw == nil {
|
||||
return map[string]any{"state": "started", "ask": id, "note": "still printing, or this ask is not known"}, 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
|
||||
}
|
||||
|
||||
var askIDPattern = regexp.MustCompile(`^[0-9a-f]{24}$`)
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// uploadFake stands for node-files.upload on another machine: it records the ask and answers a key, or refuses.
|
||||
type uploadFake struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
key string
|
||||
err error
|
||||
}
|
||||
|
||||
// withUpload puts a fake node-files in the tool side's place, and the bucket file the mesh writes from the binding
|
||||
// (files/bucket.json) where the tool reads it.
|
||||
// seen is the uploads asked so far, read under the fake's lock (the print runs in the background).
|
||||
func (f *uploadFake) seen() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.calls...)
|
||||
}
|
||||
|
||||
func withUpload(t *testing.T, key string, err error) *uploadFake {
|
||||
t.Helper()
|
||||
f := &uploadFake{key: key, err: err}
|
||||
old := uploadVia
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, node+"|"+path+"|"+bucket)
|
||||
return f.key, f.err
|
||||
}
|
||||
t.Cleanup(func() { uploadVia = old })
|
||||
file := filepath.Join(t.TempDir(), "bucket.json")
|
||||
if err := os.WriteFile(file, []byte(`{"bucket": "brother-test"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("BROTHER_BUCKET", file)
|
||||
return f
|
||||
}
|
||||
|
||||
// 1598/AC1: none, or two, of object, path+node and url is refused naming the rule; path without node is refused.
|
||||
func TestPrintTakesExactlyOneSource(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
up := withUpload(t, "k", nil)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
key := sha256Hex([]byte("x")) + ".pdf"
|
||||
for name, args := range map[string]map[string]any{
|
||||
"none": {},
|
||||
"only options": {"copies": float64(2)},
|
||||
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
|
||||
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
|
||||
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
|
||||
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
|
||||
"path without node": {"path": "/home/ops/a.pdf"},
|
||||
"node without path": {"node": "g14", "object": key},
|
||||
"the retired upload": {"upload": "u1"},
|
||||
"object and upload": {"object": key, "upload": "u1"},
|
||||
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
|
||||
} {
|
||||
_, err := r.call(t, "printer.print", args)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
}
|
||||
rule := strings.Contains(err.Error(), "exactly one of object") || strings.Contains(err.Error(), "path and node go together") ||
|
||||
strings.Contains(err.Error(), "upload is no longer")
|
||||
if !rule {
|
||||
t.Errorf("%s: the refusal does not name the rule: %v", name, err)
|
||||
}
|
||||
}
|
||||
if r.bus.asks() != 0 || len(up.seen()) != 0 || fake.body != nil {
|
||||
t.Errorf("%d asks queued, %d uploads asked, printer sent %d bytes", r.bus.asks(), len(up.seen()), len(fake.body))
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
|
||||
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
|
||||
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
|
||||
fake := newIPPFake(t, 31, 5)
|
||||
doc := pdfOf(6000)
|
||||
key := sha256Hex(doc)
|
||||
up := withUpload(t, key, nil)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
|
||||
t.Fatalf("uploads asked: %v", up.seen())
|
||||
}
|
||||
if len(r.bus.published) != 1 {
|
||||
t.Fatalf("%d asks queued", len(r.bus.published))
|
||||
}
|
||||
queued := r.bus.published[0]
|
||||
raw, _ := json.Marshal(queued)
|
||||
var fields map[string]any
|
||||
json.Unmarshal(raw, &fields)
|
||||
if queued.Op != OpPrint || queued.Object != key || queued.Copies != 2 || len(raw) >= 4096 {
|
||||
t.Errorf("queued %s", raw)
|
||||
}
|
||||
for _, banned := range []string{"path", "node", "url", "data"} {
|
||||
if _, has := fields[banned]; has {
|
||||
t.Errorf("the queued ask carries %q: %s", banned, raw)
|
||||
}
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
|
||||
t.Errorf("answer = %v", m)
|
||||
}
|
||||
if _, sent := fake.request(t); string(sent) != string(doc) {
|
||||
t.Errorf("the printer was not sent the stored document")
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC3: an upload refused by node-files is answered with node-files' reason, and nothing is printed.
|
||||
func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T) {
|
||||
fake := newIPPFake(t, 1, 3)
|
||||
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(up.seen()) != 1 || r.bus.asks() != 0 || fake.body != nil {
|
||||
t.Errorf("%d uploads, %d asks queued, printer sent %d bytes", len(up.seen()), r.bus.asks(), len(fake.body))
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC4: the device reads document-format-supported before the job and refuses a format the printer does not
|
||||
// list, naming the list; a printer that cannot be asked is sent nothing.
|
||||
func TestTheDeviceChecksTheFormatsThePrinterListsBeforeTheJob(t *testing.T) {
|
||||
fake := newIPPFake(t, 5, 3)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.asked != 1 || fake.body == nil {
|
||||
t.Fatalf("the formats were asked %d times, the job was sent: %v", fake.asked, fake.body != nil)
|
||||
}
|
||||
|
||||
fake.body, fake.formats = nil, []string{"image/urf", "image/jpeg"}
|
||||
_, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err == nil || !strings.Contains(err.Error(), "application/pdf") || !strings.Contains(err.Error(), "image/urf, image/jpeg") {
|
||||
t.Fatalf("a format not listed: error = %v", err)
|
||||
}
|
||||
if fake.body != nil {
|
||||
t.Error("a job was sent in a format the printer does not list")
|
||||
}
|
||||
|
||||
fake.formats = nil
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || fake.body != nil {
|
||||
t.Errorf("a printer that lists nothing: error = %v, sent %v", err, fake.body != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC5: brother's s3-bucket requirement shares put with seat:node-files; brother invokes seat:node-files.upload;
|
||||
// the bucket's name reaches the tool side through a file the mesh writes from the binding.
|
||||
func TestManifestSharesPutWithNodeFilesAndInvokesItsUpload(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Requires) != 1 || m.Requires[0].Name != "s3-bucket" || len(m.Requires[0].Shares) != 1 ||
|
||||
m.Requires[0].Shares[0].To != "seat:node-files" || strings.Join(m.Requires[0].Shares[0].Methods, ",") != "put" {
|
||||
t.Errorf("requires = %+v", m.Requires)
|
||||
}
|
||||
if len(m.Invokes) != 1 || m.Invokes[0] != "seat:node-files.upload" {
|
||||
t.Errorf("invokes = %v", m.Invokes)
|
||||
}
|
||||
var bucket map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "bucket" {
|
||||
bucket = r
|
||||
}
|
||||
}
|
||||
if bucket == nil || bucket["path"] != "${dir:state}/bucket.json" || bucket["template"] != "files/bucket.json" {
|
||||
t.Fatalf("bucket resource = %v", bucket)
|
||||
}
|
||||
raw, _ := os.ReadFile("../../files/bucket.json")
|
||||
if !strings.Contains(string(raw), "${bound:s3-bucket:bucket}") || strings.Contains(string(raw), "secret") {
|
||||
t.Errorf("files/bucket.json = %s", raw)
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "tools" && a["env"].(map[string]any)["BROTHER_BUCKET"] != "${dir:state}/bucket.json" {
|
||||
t.Errorf("the tools bundle is not given BROTHER_BUCKET: %v", a["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quickly makes a call wait this long before it answers started, for the test.
|
||||
func quickly(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
old := quickWait
|
||||
quickWait = d
|
||||
t.Cleanup(func() { quickWait = old })
|
||||
}
|
||||
|
||||
// 1598/AC7: a device answer slower than the call's bound is answered as started with the ask id within the bound, and
|
||||
// printer.print {ask} then returns the device's answer.
|
||||
func TestASlowPrintIsAnsweredAsStartedAndReadWithItsAsk(t *testing.T) {
|
||||
fake := newIPPFake(t, 71, 5)
|
||||
fake.delay = 400 * time.Millisecond
|
||||
quickly(t, 60*time.Millisecond)
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc) + ".pdf"
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
began := time.Now()
|
||||
got, err := r.call(t, "printer.print", map[string]any{"object": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
id, _ := m["ask"].(string)
|
||||
if m["state"] != "started" || id == "" || time.Since(began) > 300*time.Millisecond {
|
||||
t.Fatalf("answer %v after %s", m, time.Since(began))
|
||||
}
|
||||
if QuickWait != 25*time.Second {
|
||||
t.Errorf("a call waits %s, want 25s", QuickWait)
|
||||
}
|
||||
// Read while it goes on, and after: the same id.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rm := read.(map[string]any)
|
||||
if rm["state"] == nil {
|
||||
if rm["job-id"] != float64(71) || rm["object"] != key {
|
||||
t.Errorf("the outcome = %v", rm)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("never done: %v", rm)
|
||||
}
|
||||
}
|
||||
// A restarted tool process has no job: the device's answers are read.
|
||||
r2 := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r2.answers.m = map[string]json.RawMessage{"0123456789abcdef01234567": json.RawMessage(`{"job-id": 5}`)}
|
||||
if read, err := r2.call(t, "printer.print", map[string]any{"ask": "0123456789abcdef01234567"}); err != nil || read.(map[string]any)["job-id"] != float64(5) {
|
||||
t.Errorf("read from the answers: %v, %v", read, err)
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": id, "copies": float64(2)}); err == nil {
|
||||
t.Error("ask with another argument was accepted")
|
||||
}
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": "../x"}); err == nil {
|
||||
t.Error("a made-up ask id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1598/AC8: printer.print {path, node} whose upload and print together pass the bound answers started with the ask id,
|
||||
// never a timeout, and the print follows.
|
||||
func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
|
||||
fake := newIPPFake(t, 72, 5)
|
||||
fake.delay = 100 * time.Millisecond
|
||||
quickly(t, 60*time.Millisecond)
|
||||
doc := pdfOf(300)
|
||||
key := sha256Hex(doc)
|
||||
up := withUpload(t, key, nil)
|
||||
old := uploadVia
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
time.Sleep(150 * time.Millisecond) // node-files is slow
|
||||
return old(node, path, bucket)
|
||||
}
|
||||
t.Cleanup(func() { uploadVia = old })
|
||||
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
||||
r.objects.Put(t.Context(), key, "application/pdf", doc)
|
||||
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14"})
|
||||
if err != nil {
|
||||
t.Fatalf("a slow upload failed the call: %v", err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
id, _ := m["ask"].(string)
|
||||
if m["state"] != "started" || id == "" || len(up.seen()) > 1 {
|
||||
t.Fatalf("answer %v", m)
|
||||
}
|
||||
for deadline := time.Now().Add(5 * time.Second); ; {
|
||||
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rm := read.(map[string]any); rm["state"] == nil {
|
||||
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
|
||||
t.Errorf("the outcome = %v", rm)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("never done")
|
||||
}
|
||||
}
|
||||
if len(up.seen()) != 1 || len(r.bus.published) != 1 || r.bus.published[0].Object != key {
|
||||
t.Errorf("uploads %v, asks %v", up.seen(), r.bus.published)
|
||||
}
|
||||
// An upload that fails after the call answered is the outcome, read with the ask.
|
||||
uploadVia = func(node, path, bucket string) (string, error) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
|
||||
}
|
||||
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
|
||||
id2, _ := got.(map[string]any)["ask"].(string)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := r.call(t, "printer.print", map[string]any{"ask": id2}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("the failed upload's outcome: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -144,7 +145,12 @@ func newRig(t *testing.T, settings, password string) *printerRig {
|
||||
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 })
|
||||
oldFetch := fetchClient
|
||||
fetchClient = &http.Client{Transport: objects}
|
||||
t.Cleanup(func() {
|
||||
askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll
|
||||
fetchClient = oldFetch
|
||||
})
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/objectlink"
|
||||
)
|
||||
|
||||
// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device
|
||||
@@ -29,6 +32,15 @@ type Service struct {
|
||||
QuickWait, FeederWait time.Duration
|
||||
// Jobs are the scans this process follows.
|
||||
Jobs *Jobs
|
||||
// Upload asks a machine's node-files to send one of its files into the bucket and answers the key; Bucket is
|
||||
// brother's bucket. Both are the tool side's (printer.print with path and node).
|
||||
// Fetch reads the links the device process signs; DocExpiry and DocRetry are the open documents' waits. They are
|
||||
// copied into the Service when it is made, because background work reads them while a test (or a reload) changes the
|
||||
// package's own, which would race.
|
||||
Fetch *http.Client
|
||||
DocExpiry, DocRetry time.Duration
|
||||
Upload func(node, path, bucket string) (string, error)
|
||||
Bucket func() (string, error)
|
||||
}
|
||||
|
||||
// Jobs are the scans this tool process has started and not yet been asked the outcome of. A scan runs in the
|
||||
@@ -229,7 +241,7 @@ func (s *Service) finishScan(ctx context.Context, id string, wait time.Duration,
|
||||
}
|
||||
if doc, _ := ans["document"].(string); doc != "" && ans["key"] == nil {
|
||||
// A page was added to an open document: nothing to file yet, and the document is closed if it is left open.
|
||||
s.armDocument(doc, docExpiry, 0)
|
||||
s.armDocument(doc, s.docExpiry(), 0)
|
||||
return ans, nil
|
||||
}
|
||||
if ask.Document != "" && ask.Finish {
|
||||
@@ -280,26 +292,19 @@ func (s *Service) fileAnswer(ctx context.Context, ans map[string]any) (map[strin
|
||||
func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]any, error) {
|
||||
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)
|
||||
link, _ := ans["link"].(string)
|
||||
if key == "" || sum == "" || link == "" {
|
||||
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan or no link to it: %v", withoutLink(ans))
|
||||
}
|
||||
got := make([][]byte, 0, int(parts))
|
||||
for i := 1; i <= int(parts); i++ {
|
||||
p, err := s.fetchPart(ctx, key, i)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
// A part fetch is a read, safe to repeat: one retry rides out a short bus outage (issue 1536).
|
||||
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)
|
||||
// The scan is read from the store by the link the device process signed, never through the bus (ADR 0468 §4), and
|
||||
// must hash to the scan's SHA-256 before anything is written.
|
||||
var buf bytes.Buffer
|
||||
gctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := objectlink.Get(gctx, s.fetchClient(), link, &buf, objectlink.MaxDocument, sum); err != nil {
|
||||
return nil, fmt.Errorf("the scan is kept as %s, but it could not be read from the store: %w", key, err)
|
||||
}
|
||||
pdf := buf.Bytes()
|
||||
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
|
||||
if err != nil {
|
||||
at = s.Now()
|
||||
@@ -310,7 +315,7 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
|
||||
}
|
||||
body := map[string]any{}
|
||||
for k, v := range ans {
|
||||
if k != "key" && k != "size" && k != "parts" {
|
||||
if k != "key" && k != "size" && k != "link" && k != "link-seconds" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
@@ -318,20 +323,41 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
|
||||
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
|
||||
// withoutLink is an answer said in an error without its link, which carries a signature.
|
||||
func withoutLink(ans map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range ans {
|
||||
if k != "link" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
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
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) fetchClient() *http.Client {
|
||||
if s.Fetch != nil {
|
||||
return s.Fetch
|
||||
}
|
||||
return &http.Client{}
|
||||
}
|
||||
|
||||
func (s *Service) docExpiry() time.Duration {
|
||||
if s.DocExpiry > 0 {
|
||||
return s.DocExpiry
|
||||
}
|
||||
return 30 * time.Minute
|
||||
}
|
||||
|
||||
func (s *Service) docRetry() time.Duration {
|
||||
if s.DocRetry > 0 {
|
||||
return s.DocRetry
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// fetchClientDefault is what a Service made by the tools reads the links with; a test puts a client of its own in its place before it makes the tools.
|
||||
var fetchClient = &http.Client{}
|
||||
|
||||
// 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) {
|
||||
@@ -370,7 +396,7 @@ func (s *Service) armDocument(doc string, d time.Duration, attempts int) {
|
||||
if err == nil || attempts >= 10 || strings.Contains(err.Error(), "is closed already") || strings.Contains(err.Error(), "its id is wrong") {
|
||||
return
|
||||
}
|
||||
s.armDocument(doc, docRetry, attempts+1)
|
||||
s.armDocument(doc, s.docRetry(), attempts+1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,9 +414,9 @@ func (s *Service) Recover(ctx context.Context) error {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
left := docExpiry
|
||||
left := s.docExpiry()
|
||||
if at, err := time.Parse(time.RFC3339, fmt.Sprint(m["updated"])); err == nil {
|
||||
left = max(docExpiry-s.Now().Sub(at), time.Second)
|
||||
left = max(s.docExpiry()-s.Now().Sub(at), time.Second)
|
||||
}
|
||||
s.armDocument(id, left, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1601/AC1: scanner.scan reads the stored PDF by the GET link the device's answer carries (never by parts) and writes it
|
||||
// into the scan folder; the answer is the event's body plus the path.
|
||||
func TestTheScanIsReadFromTheStoreByTheLinkAndFiled(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*r.said) != 1 {
|
||||
t.Fatalf("%d events", len(*r.said))
|
||||
}
|
||||
stored, err := r.objects.Get(t.Context(), (*r.said)[0].body["object"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filed, err := os.ReadFile(body["path"].(string))
|
||||
if err != nil || string(filed) != string(stored) {
|
||||
t.Fatalf("the filed scan is not the stored one: %v", err)
|
||||
}
|
||||
if filepath.Dir(body["path"].(string)) != folder {
|
||||
t.Errorf("filed in %s, want %s", filepath.Dir(body["path"].(string)), folder)
|
||||
}
|
||||
for _, a := range r.bus.published {
|
||||
if a.Op == OpDocument {
|
||||
t.Errorf("a part of the scan was asked of the device process over the bus")
|
||||
}
|
||||
}
|
||||
for k := range (*r.said)[0].body {
|
||||
if body[k] == nil && k != "path" {
|
||||
t.Errorf("the event's %s is not in the answer", k)
|
||||
}
|
||||
}
|
||||
if body["link"] != nil || body["parts"] != nil || body["key"] != nil {
|
||||
t.Errorf("the answer carries the transport's own fields: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC2: the device's answer carries a link that expires within ten minutes and no bytes of the document; the real
|
||||
// store signs it with the binding's key for the host the binding gives.
|
||||
func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ans map[string]any
|
||||
for _, raw := range r.answers.m {
|
||||
json.Unmarshal(raw, &ans)
|
||||
}
|
||||
link, _ := ans["link"].(string)
|
||||
u, err := url.Parse(link)
|
||||
if err != nil || link == "" {
|
||||
t.Fatalf("answer %v", ans)
|
||||
}
|
||||
secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires"))
|
||||
if secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
|
||||
t.Errorf("the link lives %d s", secs)
|
||||
}
|
||||
raw, _ := json.Marshal(ans)
|
||||
if ans["data"] != nil || len(raw) > 4096 {
|
||||
t.Errorf("the answer is %d bytes and carries data %v", len(raw), ans["data"] != nil)
|
||||
}
|
||||
|
||||
// The real store: the binding's key signs, for the binding's host and port, in the module's bucket.
|
||||
s3 := NewS3(StoreFacts{Scheme: "http", At: "novox.internal", Port: "20001", Bucket: "mesh-shanks-brother", Region: "eu-west",
|
||||
Key: "binding-key", Secret: "binding-secret"})
|
||||
s3.Now = func() time.Time { return fixedNow }
|
||||
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gu, _ := url.Parse(got)
|
||||
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
|
||||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
|
||||
t.Errorf("link %s", got)
|
||||
}
|
||||
if _, err := s3.Link("GET", "abc.pdf", 11*time.Minute); err == nil {
|
||||
t.Error("a link over ten minutes was signed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC3: a body whose SHA-256 differs from the scan's writes no file and answers the mismatch.
|
||||
func TestABodyOfAnotherHashWritesNoFile(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
r.objects.corrupt = true
|
||||
_, err := r.scan(t)
|
||||
if err == nil || !strings.Contains(err.Error(), "hashes to") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if e, _ := os.ReadDir(folder); len(e) != 0 {
|
||||
t.Errorf("a scan of the wrong hash was filed: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC4: brother.scan.added is said before the file is written, once the scan is stored, and is under 4 KiB.
|
||||
func TestTheEventIsSaidBeforeTheFileIsWritten(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, folder := scanRig(t, f, Settings{Crop: false})
|
||||
var filedWhenSaid = -1
|
||||
emit := r.worker.Emit
|
||||
r.worker.Emit = func(body map[string]any, id string) error {
|
||||
e, _ := os.ReadDir(folder)
|
||||
filedWhenSaid = len(e)
|
||||
return emit(body, id)
|
||||
}
|
||||
if _, err := r.scan(t); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filedWhenSaid != 0 {
|
||||
t.Errorf("%d files in the folder when the event was said, want 0", filedWhenSaid)
|
||||
}
|
||||
said := (*r.said)[0]
|
||||
if !said.seen {
|
||||
t.Error("the event was said before the scan was stored")
|
||||
}
|
||||
raw, _ := json.Marshal(said.body)
|
||||
if len(raw) >= 4096 || said.body["path"] != nil {
|
||||
t.Errorf("the event is %d bytes, path %v", len(raw), said.body["path"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1601/AC5: no code of the device process writes a file outside its state directory: the only writes to the disk in the
|
||||
// module's code are the filing of a scan (file.go), made by the tool side (scan.go).
|
||||
func TestOnlyTheFilingWritesFiles(t *testing.T) {
|
||||
writes := []string{"os.WriteFile", "os.Create(", "os.OpenFile", "os.MkdirAll", "os.Mkdir(", "os.Rename", "os.CreateTemp", "File(s.Settings.Folder"}
|
||||
files, _ := filepath.Glob("*.go")
|
||||
for _, name := range files {
|
||||
if strings.HasSuffix(name, "_test.go") || name == "file.go" {
|
||||
continue
|
||||
}
|
||||
raw, _ := os.ReadFile(name)
|
||||
for _, w := range writes {
|
||||
if strings.Contains(string(raw), w) && !(name == "scan.go" && w == "File(s.Settings.Folder") {
|
||||
t.Errorf("%s writes with %s: only file.go does, and scan.go (the tool side) calls it", name, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile("deviceproc.go")
|
||||
if strings.Contains(string(raw), "File(") {
|
||||
t.Error("deviceproc.go files a scan")
|
||||
}
|
||||
}
|
||||
@@ -362,38 +362,3 @@ func (b *flakyBus) Publish(id string, body any) error {
|
||||
}
|
||||
return b.fakeAskBus.Publish(id, body)
|
||||
}
|
||||
|
||||
// 1536: a part fetch that fails once is asked again, and the scan is filed; one that fails twice is a failure
|
||||
// naming the stored object, so it can be filed later.
|
||||
func TestAFailedPartFetchIsAskedOnceMoreBeforeTheScanFails(t *testing.T) {
|
||||
f := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r, _ := scanRig(t, f, Settings{Crop: false})
|
||||
r.bus = nil
|
||||
flaky := &flakyBus{fakeAskBus: &fakeAskBus{worker: r.worker, answers: r.answers}, fails: 1}
|
||||
askBus = flaky
|
||||
body, err := r.scan(t)
|
||||
if err != nil {
|
||||
t.Fatalf("one failed part fetch ended the scan: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(body["path"].(string)); err != nil {
|
||||
t.Errorf("the scan is not filed: %v", err)
|
||||
}
|
||||
|
||||
f2 := newFakeScanner(t, fixture(t, "scan.jpg"))
|
||||
r2, folder := scanRig(t, f2, Settings{Crop: false})
|
||||
askBus = &flakyBus{fakeAskBus: &fakeAskBus{worker: r2.worker, answers: r2.answers}, fails: 2}
|
||||
_, err = r2.scan(t)
|
||||
if err == nil {
|
||||
t.Fatal("two failed part fetches did not fail the scan")
|
||||
}
|
||||
key := ""
|
||||
if got := *r2.said; len(got) == 1 {
|
||||
key, _ = got[0].body["object"].(string)
|
||||
}
|
||||
if key == "" || !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("the failure %q does not name the stored object %q", err, key)
|
||||
}
|
||||
if entries, _ := os.ReadDir(folder); len(entries) != 0 {
|
||||
t.Errorf("a scan whose parts failed was filed: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +108,26 @@ func (s Settings) MissingPrinter() error {
|
||||
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
|
||||
strings.Join(unset, " and "))
|
||||
}
|
||||
|
||||
// bucketFile is the file the mesh writes from the s3-bucket binding (files/bucket.json): the bucket's name and no secret.
|
||||
// BucketFile is where the tool side reads brother's bucket from: a file the mesh writes from the s3-bucket binding
|
||||
// (files/bucket.json), which holds the bucket's name and no secret.
|
||||
type bucketFile struct {
|
||||
Bucket string `json:"bucket"`
|
||||
}
|
||||
|
||||
// ReadBucket is brother's bucket, as the mesh named it.
|
||||
func ReadBucket(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("the module was started without the bucket's name (BROTHER_BUCKET)")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the bucket's name cannot be read: %w", err)
|
||||
}
|
||||
var f bucketFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Bucket == "" {
|
||||
return "", fmt.Errorf("the bucket file %s names no bucket", path)
|
||||
}
|
||||
return f.Bucket, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"bucket": "${bound:s3-bucket:bucket}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mesh-range": "${machine:mesh-range}"
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module brother
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/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=
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"s3-bucket"
|
||||
{
|
||||
"name": "s3-bucket",
|
||||
"shares": [
|
||||
{
|
||||
"to": "seat:node-files",
|
||||
"methods": [
|
||||
"put"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"invokes": [
|
||||
"seat:node-files.upload"
|
||||
],
|
||||
"binds": {
|
||||
"s3-bucket": "${dir:state}/store-binding.json"
|
||||
@@ -199,6 +212,21 @@
|
||||
"mode": "0600",
|
||||
"template": "files/store.json"
|
||||
},
|
||||
{
|
||||
"id": "bucket",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/bucket.json",
|
||||
"mode": "0644",
|
||||
"template": "files/bucket.json"
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/fetch.json",
|
||||
"owner": "brother-device",
|
||||
"mode": "0600",
|
||||
"template": "files/fetch.json"
|
||||
},
|
||||
{
|
||||
"id": "device",
|
||||
"type": "process",
|
||||
@@ -213,11 +241,13 @@
|
||||
"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"
|
||||
"BROTHER_STORE": "${dir:state}/store.json",
|
||||
"BROTHER_FETCH": "${dir:state}/fetch.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"settings",
|
||||
"store"
|
||||
"store",
|
||||
"fetch"
|
||||
],
|
||||
"bus": {
|
||||
"queues": [
|
||||
@@ -228,6 +258,9 @@
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:document-converter.convert"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
@@ -267,7 +300,8 @@
|
||||
"brother"
|
||||
],
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json"
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_BUCKET": "${dir:state}/bucket.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -476,10 +476,13 @@ type PluginFile struct {
|
||||
// what it is given. Outside hooks/, so no registered hook's name can be it.
|
||||
const GuardDir = "guard"
|
||||
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
|
||||
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
|
||||
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
|
||||
// 1744, 1743). Empty WaitsDir places no such hook.
|
||||
type GuardFiles struct {
|
||||
Exe string
|
||||
Data GuardData
|
||||
Exe string
|
||||
Data GuardData
|
||||
WaitsDir string
|
||||
}
|
||||
|
||||
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
|
||||
@@ -495,6 +498,20 @@ func GuardCommand() string {
|
||||
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
|
||||
}
|
||||
|
||||
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
|
||||
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
|
||||
func SessionStartCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
|
||||
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
func StopCommand(waitsDir string) string {
|
||||
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
|
||||
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
|
||||
@@ -533,6 +550,14 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
|
||||
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
|
||||
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
|
||||
if guard.WaitsDir != "" {
|
||||
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
|
||||
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
|
||||
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
|
||||
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
|
||||
hooks["Stop"] = append(hooks["Stop"], map[string]any{
|
||||
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
|
||||
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
|
||||
// adopt call.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
|
||||
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
|
||||
func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
i := strings.Index(msg, "claude_code_wait ")
|
||||
if i < 0 {
|
||||
t.Fatalf("no suggested call in: %s", msg)
|
||||
}
|
||||
rest := msg[i+len("claude_code_wait "):]
|
||||
start := strings.Index(rest, "{")
|
||||
if start < 0 {
|
||||
t.Fatalf("no JSON object in: %s", msg)
|
||||
}
|
||||
depth, end := 0, -1
|
||||
for j, c := range rest[start:] {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = start + j + 1
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
t.Fatalf("unbalanced JSON object in: %s", msg)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
|
||||
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
// Shared by the plugin's hooks (novox/hq ADR 0489 §5): a notice on exit 0 — the stop hook failing open or
|
||||
// letting a wait through, the session-start hook unable to use a session id — reaches the operator's screen,
|
||||
// not only the log. Claude Code shows a hook's top-level "systemMessage" field to the person; stderr alone,
|
||||
// on an allowed (exit 0) outcome, is never shown there.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// writeSystemMessage prints message as the one JSON object Claude Code shows the operator, falling back to
|
||||
// the plain words on the one error that cannot happen (a string always marshals).
|
||||
func writeSystemMessage(w io.Writer, message string) {
|
||||
raw, err := json.Marshal(map[string]string{"systemMessage": message})
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(raw))
|
||||
}
|
||||
@@ -388,6 +388,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
||||
out = append(out, proposalTools(p)...)
|
||||
out = append(out, waitTools(p)...)
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
@@ -416,6 +417,15 @@ func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
||||
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
||||
}
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits.
|
||||
if len(os.Args) == 3 && os.Args[1] == "session-start" {
|
||||
os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout))
|
||||
}
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): keeps the turn open while a wait is unwatched
|
||||
// or untold.
|
||||
if len(os.Args) == 3 && os.Args[1] == "stop" {
|
||||
os.Exit(runStop(os.Args[2], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
|
||||
// module through sudo: one answer per request on stdin, until it ends.
|
||||
if len(os.Args) == 2 && os.Args[1] == HelperArg {
|
||||
@@ -463,6 +473,7 @@ func main() {
|
||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||
Now: time.Now, Write: writeManaged}
|
||||
go proposing.watch(p)
|
||||
waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now}
|
||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
||||
|
||||
@@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
|
||||
}
|
||||
|
||||
// MeshToolsEvery is how often the controller is asked again.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start
|
||||
// <waits-dir>`, run by the agent's own account on every start, resume, /clear and /compact. The tool runner
|
||||
// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself
|
||||
// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if
|
||||
// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a
|
||||
// session with a brand new id (a /clear gives one) can see a name it may want to adopt.
|
||||
//
|
||||
// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the
|
||||
// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into
|
||||
// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed
|
||||
// file is not an error here: there is simply nothing open to report.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStartInput is what the hook is given on stdin.
|
||||
type SessionStartInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait,
|
||||
// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node
|
||||
// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads.
|
||||
func SessionStartMessage(sessionID, waitsDir string, now time.Time) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID)
|
||||
if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 {
|
||||
b.WriteString("Waits already bound to this session's own id:\n")
|
||||
for _, w := range f.Waits {
|
||||
age := now.Sub(w.At).Round(time.Second)
|
||||
state := "no watcher yet"
|
||||
if w.Watcher != "" {
|
||||
state = "watched"
|
||||
}
|
||||
if w.PastBound(now) {
|
||||
state += ", past its bound"
|
||||
if w.Said {
|
||||
state += ", said"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state)
|
||||
}
|
||||
}
|
||||
if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 {
|
||||
b.WriteString("Open waits on this node, by name:\n")
|
||||
for _, n := range idx.Names {
|
||||
fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+
|
||||
"{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339),
|
||||
n.Name+"@"+idx.Node, sessionID)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3).
|
||||
func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // never fails the session's start
|
||||
}
|
||||
}()
|
||||
var in SessionStartInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+
|
||||
"(letters, digits, '.', '_' and '-' only)", sessionID))
|
||||
return 0
|
||||
}
|
||||
fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now()))
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
|
||||
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("code %d, stdout %s", code, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
|
||||
t.Fatalf("stdout does not say how to name the session: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
|
||||
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
||||
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
|
||||
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
|
||||
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
|
||||
}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
|
||||
|
||||
now := at.Add(5 * time.Minute)
|
||||
msg := SessionStartMessage("sess-10", waitsDir, now)
|
||||
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
|
||||
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
|
||||
t.Fatalf("the past-bound wait is not described:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "5m0s ago") {
|
||||
t.Fatalf("the age is not said:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
|
||||
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a missing file printed more than the id line: %q", msg)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
|
||||
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
|
||||
t.Fatalf("a malformed file printed more than the id line: %q", msg)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
|
||||
t.Fatalf("unreadable stdin failed the session start: code %d", code)
|
||||
}
|
||||
var out2 bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
|
||||
type panicReader struct{}
|
||||
|
||||
func (panicReader) Read([]byte) (int, error) { panic("boom") }
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
|
||||
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
|
||||
// the id could not be used.
|
||||
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
|
||||
// read through it.
|
||||
outside := filepath.Join(waitsDir, "../../x.json")
|
||||
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
|
||||
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
|
||||
t.Cleanup(func() { _ = os.Remove(outside) })
|
||||
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
var out bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
|
||||
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
||||
t.Fatalf("id %q: code %d", id, code)
|
||||
}
|
||||
if strings.Contains(out.String(), "SECRET-DATA") {
|
||||
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
|
||||
}
|
||||
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(out.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
|
||||
t.Fatalf("blank id: code %d, out %q", code, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
|
||||
|
||||
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
|
||||
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
|
||||
// module.
|
||||
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
||||
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
||||
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
||||
}}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
|
||||
|
||||
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
|
||||
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
|
||||
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
||||
}
|
||||
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
|
||||
t.Helper()
|
||||
raw, err := indented(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, path, string(raw))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
|
||||
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
|
||||
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
|
||||
// is local, under 5 s, and needs no network.
|
||||
//
|
||||
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
|
||||
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
|
||||
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
|
||||
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
|
||||
// unreadable or malformed waits file, or a panic, always allows.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StopInput is what the hook is given on stdin.
|
||||
type StopInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
}
|
||||
|
||||
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
|
||||
// turn end anyway (1743/AC5).
|
||||
const MostStopRefusals = 3
|
||||
|
||||
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
|
||||
type StopDecision struct {
|
||||
Refuse bool
|
||||
Reason string
|
||||
Counters map[string]int
|
||||
}
|
||||
|
||||
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
|
||||
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
if w.Said {
|
||||
return false, ""
|
||||
}
|
||||
if !w.PastBound(now) {
|
||||
if w.Watcher != "" {
|
||||
return false, ""
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"%s, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
|
||||
watcherAdvice(w), session, w.By, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
|
||||
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
|
||||
func watcherAdvice(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
|
||||
"that rereads %s", w.Family, w.Read.Address)
|
||||
}
|
||||
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
|
||||
}
|
||||
|
||||
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
|
||||
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
|
||||
// blocks the stop is named; the others are read again next time.
|
||||
//
|
||||
// A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of
|
||||
// every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that
|
||||
// reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so.
|
||||
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
|
||||
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
|
||||
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
|
||||
}
|
||||
for _, w := range f.Waits {
|
||||
blocks, reason := waitBlocksStop(session, w, now)
|
||||
if !blocks {
|
||||
continue
|
||||
}
|
||||
count := previous[w.ID] + 1
|
||||
if count > MostStopRefusals {
|
||||
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
|
||||
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
|
||||
}
|
||||
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
|
||||
}
|
||||
return StopDecision{Counters: map[string]int{}}
|
||||
}
|
||||
|
||||
func refusalCountersPath(waitsDir, session string) string {
|
||||
return filepath.Join(waitsDir, session+".refusals.json")
|
||||
}
|
||||
|
||||
func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
|
||||
if err != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
|
||||
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
|
||||
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
|
||||
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
|
||||
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
|
||||
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
|
||||
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
|
||||
// item 2).
|
||||
//
|
||||
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
|
||||
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
|
||||
// time, the waits folder itself; removing a file that was never there is not an error.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
|
||||
path := refusalCountersPath(waitsDir, session)
|
||||
if len(m) == 0 {
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
|
||||
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
|
||||
func notice(stdout, stderr io.Writer, reason string) {
|
||||
fmt.Fprintln(stderr, reason)
|
||||
writeSystemMessage(stdout, reason)
|
||||
}
|
||||
|
||||
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
|
||||
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0 // a panic always allows (1743/AC6)
|
||||
}
|
||||
}()
|
||||
var in StopInput
|
||||
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
|
||||
return 0
|
||||
}
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
if sessionID == "" {
|
||||
return 0
|
||||
}
|
||||
// Review round 1, item 1: a session id is never used to build a path until it is checked against
|
||||
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
|
||||
if !waitSessionID.MatchString(sessionID) {
|
||||
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
|
||||
"'.', '_' and '-' only); this stop is allowed", sessionID))
|
||||
return 0
|
||||
}
|
||||
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
|
||||
counters := readRefusalCounters(waitsDir, sessionID)
|
||||
d := DecideStop(sessionID, f, err, counters, time.Now())
|
||||
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
|
||||
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
|
||||
// refusing anyway could hold the turn forever, so this call is let through instead.
|
||||
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
|
||||
"cannot hold the turn forever", werr))
|
||||
return 0
|
||||
}
|
||||
if d.Reason != "" {
|
||||
fmt.Fprintln(stderr, d.Reason)
|
||||
if !d.Refuse {
|
||||
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
|
||||
}
|
||||
}
|
||||
if d.Refuse {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func openWait(id string, bound int) Wait {
|
||||
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
|
||||
}
|
||||
|
||||
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
|
||||
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
|
||||
// watcher that rereads the address).
|
||||
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
||||
w := openWait("w-1", 10)
|
||||
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
w.Family = "mesh-delivery.decided"
|
||||
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
|
||||
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
|
||||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
|
||||
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
|
||||
watched := openWait("w-2", 10)
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
|
||||
t.Fatalf("a watched wait within its bound was refused: %+v", d)
|
||||
}
|
||||
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
|
||||
t.Fatalf("no open waits was refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
|
||||
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
|
||||
unwatched := openWait("w-3", 5)
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
|
||||
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
|
||||
t.Fatalf("unwatched past bound: %+v", d)
|
||||
}
|
||||
watched := unwatched
|
||||
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
|
||||
t.Fatalf("watched past bound was allowed: %+v", d)
|
||||
}
|
||||
said := watched
|
||||
said.Said = true
|
||||
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
|
||||
t.Fatalf("said past bound was still refused: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
|
||||
// stdout; the decision does not touch the network and answers at once.
|
||||
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
|
||||
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
|
||||
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
||||
start := time.Now()
|
||||
code := runStop(waitsDir, stdin, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
|
||||
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
if took := time.Since(start); took > 5*time.Second {
|
||||
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
|
||||
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
|
||||
// simply allow; a notice there would fire constantly for nothing wrong.
|
||||
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
|
||||
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
|
||||
if d.Refuse || d.Reason != "" {
|
||||
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir() // no file at all for sess-4d
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d", code)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
|
||||
// it was left unwatched; the counter resets when the wait changes.
|
||||
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
||||
w := openWait("w-5", 10)
|
||||
counters := map[string]int{}
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d.Refuse {
|
||||
t.Fatalf("refusal %d was allowed: %+v", i, d)
|
||||
}
|
||||
counters = d.Counters
|
||||
}
|
||||
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
|
||||
t.Fatalf("the fourth refusal was not let through: %+v", d)
|
||||
}
|
||||
if len(d.Counters) != 0 {
|
||||
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
|
||||
}
|
||||
|
||||
// A different wait starts its own count from zero.
|
||||
w2 := openWait("w-5b", 10)
|
||||
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
|
||||
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
|
||||
t.Fatalf("a different wait did not start its own count: %+v", d2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC6: a panic answers allow.
|
||||
func TestStopAPanicAllows(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
|
||||
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
|
||||
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
||||
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): the let-through notice reaches the screen too, not only the log.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
||||
// 1749, follow-up).
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
||||
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
||||
// every session.
|
||||
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
||||
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
||||
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
||||
t.Fatalf("the counter file: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
||||
// counter file rather than leaving a stale "{}" behind.
|
||||
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
path := filepath.Join(waitsDir, "sess-8.json")
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
||||
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
||||
t.Fatalf("the counter file was not written: %v", err)
|
||||
}
|
||||
|
||||
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
||||
var stdout2, stderr2 bytes.Buffer
|
||||
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
||||
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
|
||||
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
|
||||
// Stop hook allows, saying why.
|
||||
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
|
||||
}
|
||||
escaped := filepath.Join(waitsDir, id+".refusals.json")
|
||||
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
|
||||
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
|
||||
}
|
||||
}
|
||||
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blank id: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
|
||||
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
|
||||
// never takes effect.
|
||||
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root: a directory's write bit cannot be taken away")
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
|
||||
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
|
||||
if err := os.Chmod(waitsDir, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "counter") {
|
||||
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
noFamily := openWait("w-nf", 10)
|
||||
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
|
||||
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
|
||||
// together with 1744/AC4 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
|
||||
// refuses a Stop hook (or any other) at every scope.
|
||||
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
|
||||
if !isError || !strings.Contains(text, "issue 340") {
|
||||
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
||||
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
||||
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
||||
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
||||
// filesystem only through the helper's request/response protocol bumps its count.
|
||||
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
was := fsys
|
||||
fsys = f
|
||||
t.Cleanup(func() { fsys = was })
|
||||
|
||||
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
||||
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted.asked.Load() == 0 {
|
||||
t.Fatal("the counter file was written directly, never through the helper")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
||||
t.Fatalf("the written counters: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -261,12 +261,12 @@ func TestTheOperatorsManagedSettingsAreWhatSessionsObey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin's hooks are the mesh's own guard on the shell (novox/hq ADR 0245) and nothing else, whatever the
|
||||
// configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
// The plugin's only hooks are the guard, the session-start hook and the Stop hook (novox/hq ADR 0245, ADR
|
||||
// 0489) — never a hook a registration named, whatever the configuration given holds (novox/hq issue 340).
|
||||
func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *testing.T) {
|
||||
c := Config{Mesh: []Item{{Kind: KindHook, Name: "planted", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||
Command: "${HOOK_DIR}/x.sh", Files: map[string]string{"x.sh": "#!/bin/sh\n"}}}}
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true"})
|
||||
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true", WaitsDir: "/home/agent/.claude/nox-mesh/waits"})
|
||||
root := "plugins/" + Plugin + "/"
|
||||
var hooks struct {
|
||||
Hooks map[string][]struct {
|
||||
@@ -279,9 +279,16 @@ func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hooks.Hooks) != 1 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
if len(hooks.Hooks) != 3 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
|
||||
hooks.Hooks["PreToolUse"][0].Hooks[0].Command != GuardCommand() {
|
||||
t.Fatalf("the plugin's hooks: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
t.Fatalf("the guard's hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["SessionStart"]) != 1 || hooks.Hooks["SessionStart"][0].Matcher != "startup|resume|clear|compact" ||
|
||||
hooks.Hooks["SessionStart"][0].Hooks[0].Command != SessionStartCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the session-start hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if len(hooks.Hooks["Stop"]) != 1 || hooks.Hooks["Stop"][0].Hooks[0].Command != StopCommand("/home/agent/.claude/nox-mesh/waits") {
|
||||
t.Fatalf("the Stop hook: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
for path := range plugin {
|
||||
if strings.HasPrefix(path, root+"hooks/planted") {
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer
|
||||
// to — an Approve, a delivery's end, anything it started and must act on when it ends
|
||||
// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and
|
||||
// nothing watches it.
|
||||
//
|
||||
// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as
|
||||
// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a
|
||||
// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name
|
||||
// to the session that last called for it, with that call's time, so the same name's waits are found again
|
||||
// across a /clear once the new session has adopted it.
|
||||
//
|
||||
// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is
|
||||
// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every
|
||||
// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index,
|
||||
// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session
|
||||
// with a brand new id what it might want to adopt, without calling back into this module.
|
||||
//
|
||||
// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process —
|
||||
// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent
|
||||
// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already
|
||||
// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's
|
||||
// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its
|
||||
// own, fsys is the plain filesystem and the write reaches the operator's own home directly.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// WaitsState is the module's state the wait lists are kept in.
|
||||
const WaitsState = "waits"
|
||||
|
||||
// WaitsDir is where the mirrored files live under the agent's home.
|
||||
const WaitsDir = "nox-mesh/waits"
|
||||
|
||||
// WaitPrefix starts every wait's id.
|
||||
const WaitPrefix = "w-"
|
||||
|
||||
// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5).
|
||||
const MostWaits = 50
|
||||
|
||||
// The bounds of a wait's bound, in minutes (1629/AC5).
|
||||
const (
|
||||
ShortestWaitBound = 1
|
||||
LongestWaitBound = 1440
|
||||
)
|
||||
|
||||
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
|
||||
const mostWaitAttempts = 20
|
||||
|
||||
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
|
||||
// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a
|
||||
// safe name (no "/", so it cannot name a file outside the waits directory).
|
||||
func requireSessionID(session string) (string, error) {
|
||||
session = strings.TrimSpace(session)
|
||||
if !waitSessionID.MatchString(session) {
|
||||
return "", errors.New("session is required: the id the SessionStart hook named")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// WaitRead is the verb a session reads to see where one wait stands.
|
||||
type WaitRead struct {
|
||||
Address string `json:"address"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was
|
||||
// added under.
|
||||
type Wait struct {
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
What string `json:"what"`
|
||||
Read WaitRead `json:"read"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Bound int `json:"bound"` // minutes
|
||||
At time.Time `json:"at"`
|
||||
Watcher string `json:"watcher,omitempty"`
|
||||
Said bool `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// PastBound says whether now is past this wait's bound.
|
||||
func (w Wait) PastBound(now time.Time) bool {
|
||||
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
|
||||
}
|
||||
|
||||
// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open
|
||||
// waits, and the binding to whichever session currently answers to that name — empty until the first call
|
||||
// names it, by add or by adopt.
|
||||
type NamedWaits struct {
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall,omitempty"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits
|
||||
// of whichever name is currently bound to this session, and the time they were written.
|
||||
type WaitsFile struct {
|
||||
Session string `json:"session"`
|
||||
At time.Time `json:"at"`
|
||||
Waits []Wait `json:"waits"`
|
||||
}
|
||||
|
||||
// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook
|
||||
// (novox/hq ADR 0490 §2).
|
||||
type NodeIndexEntry struct {
|
||||
Name string `json:"name"`
|
||||
Open int `json:"open"`
|
||||
CurrentSession string `json:"currentSession,omitempty"`
|
||||
LastCall time.Time `json:"lastCall"`
|
||||
}
|
||||
|
||||
// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that
|
||||
// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the
|
||||
// SessionStart hook can say "if this is you, adopt" without calling back into this module.
|
||||
type NodeIndex struct {
|
||||
Node string `json:"node"`
|
||||
At time.Time `json:"at"`
|
||||
Names []NodeIndexEntry `json:"names"`
|
||||
}
|
||||
|
||||
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
|
||||
// revision read; Keys lists every name kept, for the node-wide index.
|
||||
type WaitStore interface {
|
||||
Get(key string) (json.RawMessage, uint64, bool, error)
|
||||
Create(key string, value any) error
|
||||
Update(key string, value any, revision uint64) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// keptWaits is the `waits` state on the bus.
|
||||
type keptWaits struct{ s stdio.KeptState }
|
||||
|
||||
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := k.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
func (k keptWaits) Create(key string, value any) error {
|
||||
_, err := k.s.Create(key, value)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Update(key string, value any, revision uint64) error {
|
||||
_, err := k.s.Update(key, value, revision)
|
||||
return err
|
||||
}
|
||||
func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() }
|
||||
|
||||
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
|
||||
type Waiting struct {
|
||||
Store WaitStore
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
var waiting *Waiting
|
||||
|
||||
// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two
|
||||
// nodes never collide over the same name.
|
||||
func waitsKey(p Paths, name string) string { return p.Node + "/" + name }
|
||||
|
||||
// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the
|
||||
// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line.
|
||||
func WaitsDirPath(p Paths) string {
|
||||
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir))
|
||||
}
|
||||
|
||||
// waitsFilePath is where one session id's mirrored waits are.
|
||||
func waitsFilePath(p Paths, session string) string {
|
||||
return filepath.Join(WaitsDirPath(p), session+".json")
|
||||
}
|
||||
|
||||
// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with
|
||||
// a session id, since waitSessionID never allows a leading "_".
|
||||
func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") }
|
||||
|
||||
func newWaitID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return WaitPrefix + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
|
||||
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
|
||||
func addressShaped(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.ContainsAny(s, " \t\n\r") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndex(s, ".")
|
||||
return dot > 0 && dot < len(s)-1
|
||||
}
|
||||
|
||||
func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) {
|
||||
raw, rev, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
return NamedWaits{}, 0, found, err
|
||||
}
|
||||
var nw NamedWaits
|
||||
_ = json.Unmarshal(raw, &nw)
|
||||
return nw, rev, true, nil
|
||||
}
|
||||
|
||||
// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the
|
||||
// revision read — created when there was none yet — retried when another write won the race.
|
||||
func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) {
|
||||
for i := 0; i < mostWaitAttempts; i++ {
|
||||
nw, rev, found, err := readNamedWaits(store, key)
|
||||
if err != nil {
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
next, err := mutate(nw)
|
||||
if err != nil {
|
||||
return NamedWaits{}, err
|
||||
}
|
||||
if !found {
|
||||
err = store.Create(key, next)
|
||||
} else {
|
||||
err = store.Update(key, next, rev)
|
||||
}
|
||||
if err == nil {
|
||||
return next, nil
|
||||
}
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
|
||||
}
|
||||
return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again")
|
||||
}
|
||||
|
||||
// tempSuffix is a short random name for a temp file, so two writes racing for the same path never share one
|
||||
// (review round 2, item a): each gets its own, and only the rename is a single atomic step.
|
||||
func tempSuffix() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// writeSessionFile rewrites one session id's mirrored file whole, atomically (its own temp file, then
|
||||
// rename): it holds exactly the open waits given and the time of this write (1629/AC3). waits is always the
|
||||
// union of every name currently bound to sessionID (sessionWaitsUnion), never only the name an op acted on:
|
||||
// a second name bound to the same id must stay enforced (ADR 0490 §1, review round 2 item 2).
|
||||
func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error {
|
||||
path := waitsFilePath(p, sessionID)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if waits == nil {
|
||||
waits = []Wait{}
|
||||
}
|
||||
raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// sessionWaitsUnion is every open wait of every name on this node currently bound to session (ADR 0490 §1):
|
||||
// what that session id's mirrored file must hold, whole — never only the one name an op just acted on, or a
|
||||
// second name bound to the same id would stop being enforced the moment any op touched the first.
|
||||
func sessionWaitsUnion(store WaitStore, node, session string) ([]Wait, error) {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := node + "/"
|
||||
var out []Wait
|
||||
names := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(names) // deterministic order: tests, and a session reading its own file, see the same thing
|
||||
for _, k := range names {
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || nw.CurrentSession != session {
|
||||
continue
|
||||
}
|
||||
out = append(out, nw.Waits...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeSessionWaits rewrites session's mirrored file with sessionWaitsUnion: every call that changes a
|
||||
// name's waits or its binding goes through this, never writeSessionFile directly with one name's waits
|
||||
// alone.
|
||||
func writeSessionWaits(p Paths, store WaitStore, session string, now time.Time) error {
|
||||
union, err := sessionWaitsUnion(store, p.Node, session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the session's other names could not be read: %w", err)
|
||||
}
|
||||
return writeSessionFile(p, session, union, now)
|
||||
}
|
||||
|
||||
// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this
|
||||
// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on
|
||||
// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs.
|
||||
func writeNodeIndex(p Paths, store WaitStore, now time.Time) error {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the node-wide wait index could not be read: %w", err)
|
||||
}
|
||||
prefix := p.Node + "/"
|
||||
idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}}
|
||||
for _, k := range keys {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nw, _, found, err := readNamedWaits(store, k)
|
||||
if err != nil || !found || len(nw.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits),
|
||||
CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()})
|
||||
}
|
||||
sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name })
|
||||
path := nodeIndexPath(p)
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := indented(idx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp-" + tempSuffix()
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is
|
||||
// kept under (novox/hq ADR 0490 §1), never the session id.
|
||||
func callerName(p Paths, by string) (string, error) {
|
||||
by = strings.TrimSpace(by)
|
||||
if who, node, found := strings.Cut(by, "@"); found {
|
||||
if node != p.Node {
|
||||
return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node)
|
||||
}
|
||||
return who, nil
|
||||
}
|
||||
if by == "" {
|
||||
return "", errors.New("by is required: your session's name, as who@node or who")
|
||||
}
|
||||
return by, nil
|
||||
}
|
||||
|
||||
// bindCaller checks the binding for name against session, refusing when another, more recently bound
|
||||
// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the
|
||||
// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is
|
||||
// claimed by the first session that calls for it, add included.
|
||||
func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error {
|
||||
if nw.CurrentSession != "" && nw.CurrentSession != session {
|
||||
return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+
|
||||
"is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession,
|
||||
nw.LastCall.UTC().Format(time.RFC3339), by, session)
|
||||
}
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// findWait is the index of id in waits, or -1.
|
||||
func findWait(waits []Wait, id string) int {
|
||||
for i, w := range waits {
|
||||
if w.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// WaitInput is what claude_code_wait's add takes.
|
||||
type WaitInput struct {
|
||||
Session string
|
||||
By string
|
||||
What string
|
||||
Read WaitRead
|
||||
Family string
|
||||
Bound int
|
||||
}
|
||||
|
||||
// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another
|
||||
// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3).
|
||||
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(in.Session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, in.By)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
what := strings.TrimSpace(in.What)
|
||||
if what == "" {
|
||||
return nil, errors.New("what is required: say what you are waiting for")
|
||||
}
|
||||
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
|
||||
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
|
||||
}
|
||||
address := strings.TrimSpace(in.Read.Address)
|
||||
if !addressShaped(address) {
|
||||
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
|
||||
}
|
||||
w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
|
||||
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
|
||||
key := waitsKey(p, name)
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
if len(nw.Waits) >= MostWaits {
|
||||
return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits...), w)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err)
|
||||
}
|
||||
say("wait %s added for %s (session %s): %s", w.ID, name, session, what)
|
||||
return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil
|
||||
}
|
||||
|
||||
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
|
||||
func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
if watcher == "" {
|
||||
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits[i].Watcher = watcher
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "watcher": watcher}, nil
|
||||
}
|
||||
|
||||
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
|
||||
func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...)
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
say("wait %s for %s (session %s) done: %s", id, name, session, outcome)
|
||||
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
|
||||
}
|
||||
|
||||
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
|
||||
// (1629/AC2).
|
||||
func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required: the one add answered")
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
|
||||
return nw, err
|
||||
}
|
||||
i := findWait(nw.Waits, id)
|
||||
if i < 0 {
|
||||
return nw, fmt.Errorf("no open wait %s for %s", id, name)
|
||||
}
|
||||
if !nw.Waits[i].PastBound(now) {
|
||||
return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
|
||||
}
|
||||
nw.Waits[i].Said = true
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
|
||||
}
|
||||
_ = writeNodeIndex(p, store, now)
|
||||
return map[string]any{"id": id, "said": true}, nil
|
||||
}
|
||||
|
||||
// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1),
|
||||
// and who currently holds the name. Read-only (review round 2, item c): unlike every other op it never
|
||||
// claims an unbound name, and never refuses a session that is not (or not yet) the one bound to it — a
|
||||
// session may want to look before adopting.
|
||||
func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nw, _, _, err := readNamedWaits(store, waitsKey(p, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the wait list could not be read: %w", err)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, w := range nw.Waits {
|
||||
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
|
||||
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
|
||||
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
|
||||
if len(w.Read.Arguments) > 0 {
|
||||
entry["arguments"] = w.Read.Arguments
|
||||
}
|
||||
if w.Family != "" {
|
||||
entry["family"] = w.Family
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
answer := map[string]any{"session": session, "name": name, "open": out}
|
||||
if nw.CurrentSession != "" {
|
||||
answer["held-by"] = map[string]any{"session": nw.CurrentSession, "last-called": nw.LastCall.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the
|
||||
// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a
|
||||
// session that no longer owns a name never finds its old waits in its own file, and the new one does right
|
||||
// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own.
|
||||
func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) {
|
||||
session, err := requireSessionID(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := callerName(p, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := waitsKey(p, name)
|
||||
var oldSession string
|
||||
var oldLastCall time.Time
|
||||
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
|
||||
oldSession, oldLastCall = nw.CurrentSession, nw.LastCall
|
||||
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC()
|
||||
return nw, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tookOver := oldSession != "" && oldSession != session
|
||||
if tookOver {
|
||||
// oldSession's file is rewritten from the union of whatever names remain bound to it (ADR 0490 §1,
|
||||
// review round 2 item 2): name no longer is one of them, but another might still be — never simply
|
||||
// emptied.
|
||||
if err := writeSessionWaits(p, store, oldSession, now); err != nil {
|
||||
return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := writeSessionWaits(p, store, session, now); err != nil {
|
||||
return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err)
|
||||
}
|
||||
if err := writeNodeIndex(p, store, now); err != nil {
|
||||
say("writing the node-wide wait index after %s was adopted: %v", name, err)
|
||||
}
|
||||
// Review round 2, item b: a fresh three-strikes count starts under the new binding, on both ids.
|
||||
waitsDir := WaitsDirPath(p)
|
||||
_ = writeRefusalCounters(waitsDir, session, map[string]int{})
|
||||
if tookOver {
|
||||
_ = writeRefusalCounters(waitsDir, oldSession, map[string]int{})
|
||||
}
|
||||
answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)}
|
||||
if tookOver {
|
||||
answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)}
|
||||
say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession,
|
||||
oldLastCall.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
say("%s adopted by session %s", name, session)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks
|
||||
// have no launch environment, only their command line, so they never recompute this from Paths.
|
||||
func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") }
|
||||
|
||||
// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument.
|
||||
func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") }
|
||||
|
||||
// readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own
|
||||
// account (never through fsys or the module's state), and never calling back into this module.
|
||||
func readWaitsFile(path string) (WaitsFile, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
var f WaitsFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return WaitsFile{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through
|
||||
// fsys or the module's state, and never calling back into this module.
|
||||
func readNodeIndex(path string) (NodeIndex, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
var idx NodeIndex
|
||||
if err := json.Unmarshal(raw, &idx); err != nil {
|
||||
return NodeIndex{}, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq
|
||||
// ADR 0489, ADR 0490).
|
||||
func waitTools(p Paths) []stdio.Tool {
|
||||
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_wait",
|
||||
Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " +
|
||||
"still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " +
|
||||
"it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " +
|
||||
"follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " +
|
||||
"by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " +
|
||||
"name is bound to whichever session last called for it; if your own id changed (a /clear) you must " +
|
||||
"adopt your name again before it answers to you — the SessionStart hook lists every name on this " +
|
||||
"node with an open wait and how. adopt takes the name over at once, however recently another " +
|
||||
"session called for it; that session is told so, naming yours, the next time it calls. add takes " +
|
||||
"what (what you are waiting for), read (the verb that answers where it stands: {address, " +
|
||||
"arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " +
|
||||
"answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " +
|
||||
"mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " +
|
||||
"from the open list. said takes id and marks a wait past its bound as told to the operator — " +
|
||||
"refused while it is still within its bound. list answers the open waits, with their age and " +
|
||||
"whether each is past its bound.",
|
||||
Input: map[string]any{
|
||||
"op": str("add, watched, done, said, list or adopt"),
|
||||
"session": str("required: your session's current id"),
|
||||
"by": str("required: your session's name, as who@node or who"),
|
||||
"what": str("add: what you are waiting for, in plain words"),
|
||||
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
|
||||
"properties": map[string]any{
|
||||
"address": str("the address, as mesh_call takes it"),
|
||||
"arguments": map[string]any{"type": "object", "description": "its arguments"},
|
||||
}},
|
||||
"family": str("add: the event family mesh_wait can wait on, when there is one"),
|
||||
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
|
||||
"id": str("watched, done or said: the id add answered"),
|
||||
"watcher": str("watched: what follows this wait"),
|
||||
"outcome": str("done: what the wait ended with"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if waiting == nil {
|
||||
return nil, notLaunched
|
||||
}
|
||||
session, by := strArg(a, "session"), strArg(a, "by")
|
||||
now := time.Now()
|
||||
if waiting.Now != nil {
|
||||
now = waiting.Now()
|
||||
}
|
||||
switch op := strArg(a, "op"); op {
|
||||
case "add":
|
||||
read := WaitRead{}
|
||||
if m, ok := a["read"].(map[string]any); ok {
|
||||
read.Address = strArg(m, "address")
|
||||
if args, ok := m["arguments"].(map[string]any); ok {
|
||||
read.Arguments = args
|
||||
}
|
||||
}
|
||||
bound := 0
|
||||
if n, ok := a["bound_minutes"].(float64); ok {
|
||||
bound = int(n)
|
||||
}
|
||||
return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read,
|
||||
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
|
||||
case "watched":
|
||||
return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
|
||||
case "done":
|
||||
return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
|
||||
case "said":
|
||||
return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now)
|
||||
case "list":
|
||||
return ListWaits(p, session, by, waiting.Store, now)
|
||||
case "adopt":
|
||||
return AdoptName(p, by, session, waiting.Store, now)
|
||||
default:
|
||||
return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op)
|
||||
}
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package main
|
||||
|
||||
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
|
||||
type memWaits struct {
|
||||
mu sync.Mutex
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
}
|
||||
|
||||
func newMemWaits() *memWaits {
|
||||
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.m[key]
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memWaits) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
s.n++
|
||||
s.m[key], s.rev[key] = raw, s.n
|
||||
return err
|
||||
}
|
||||
func (s *memWaits) Create(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.m[key]; ok {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Update(key string, value any, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.rev[key] != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memWaits) Keys() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.m))
|
||||
for k := range s.m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session.
|
||||
func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string {
|
||||
t.Helper()
|
||||
out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
|
||||
Bound: 10}, store, at)
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
return out["id"].(string)
|
||||
}
|
||||
|
||||
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
||||
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
|
||||
if !strings.HasPrefix(id, WaitPrefix) {
|
||||
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
|
||||
}
|
||||
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
|
||||
t.Fatalf("%+v", open)
|
||||
}
|
||||
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["past_bound"] != true {
|
||||
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
||||
// it from the open list.
|
||||
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
||||
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
|
||||
t.Fatalf("said before the bound: %v", err)
|
||||
}
|
||||
if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute))
|
||||
open := out["open"].([]map[string]any)
|
||||
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
|
||||
t.Fatalf("watcher not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil {
|
||||
t.Fatalf("said past the bound: %v", err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute))
|
||||
open = out["open"].([]map[string]any)
|
||||
if open[0]["said"] != true {
|
||||
t.Fatalf("said not kept: %+v", open[0])
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute))
|
||||
if len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("done did not remove it from the open list: %v", out)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
|
||||
t.Fatal("done on an already-done wait was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
||||
// and the write time.
|
||||
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
|
||||
t.Fatalf("%+v", f)
|
||||
}
|
||||
// Written whole: no .mesh-tmp left beside it.
|
||||
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("a temp file was left beside it: %v", err)
|
||||
}
|
||||
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait",
|
||||
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
||||
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
|
||||
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
|
||||
// after a /clear that keeps calling with the same session id; a different name lists none of them.
|
||||
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
|
||||
|
||||
// A restart: a fresh Waiting over the same store reads the same waits.
|
||||
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
|
||||
_ = restarted
|
||||
out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again after a restart: %v %v", out, err)
|
||||
}
|
||||
|
||||
// The same session id, called again: still lists it.
|
||||
out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("not read again for the same name: %v %v", out, err)
|
||||
}
|
||||
|
||||
// A different name, same node: none of them.
|
||||
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("another name saw them: %v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
|
||||
// address, and more than 50 open waits per name, naming the argument.
|
||||
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
base := func() WaitInput {
|
||||
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
||||
}
|
||||
for name, change := range map[string]struct {
|
||||
mutate func(*WaitInput)
|
||||
want string
|
||||
}{
|
||||
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
|
||||
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
||||
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
|
||||
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
|
||||
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
|
||||
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
|
||||
} {
|
||||
in := base()
|
||||
change.mutate(&in)
|
||||
store := newMemWaits()
|
||||
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// More than 50 open waits per name.
|
||||
store := newMemWaits()
|
||||
for i := 0; i < MostWaits; i++ {
|
||||
if _, err := AddWait(p, base(), store, t0); err != nil {
|
||||
t.Fatalf("wait %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
|
||||
t.Fatalf("the 51st wait was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
||||
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
|
||||
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
by := "fix-1629@g14"
|
||||
for _, bad := range []string{"", "a/b", "../x"} {
|
||||
store := newMemWaits()
|
||||
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("list %q: %v", bad, err)
|
||||
}
|
||||
if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("watched %q: %v", bad, err)
|
||||
}
|
||||
if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("done %q: %v", bad, err)
|
||||
}
|
||||
if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
||||
t.Errorf("said %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1629/AC6: the tool is served through the module's own dispatch.
|
||||
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
var added map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &added); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := added["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("no id answered: %s", text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, id) {
|
||||
t.Fatalf("list: %v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
|
||||
"id": id, "outcome": "approved"})
|
||||
if isErr {
|
||||
t.Fatalf("done: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADR 0490: a name's waits survive a session's id changing, by adopting -----------------------------
|
||||
|
||||
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
|
||||
// a separate adopt first.
|
||||
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
|
||||
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
|
||||
// name (any op) is refused, naming the session that holds it and when it last called.
|
||||
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") {
|
||||
t.Fatalf("add from a different session was not refused: %v", err)
|
||||
}
|
||||
|
||||
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
|
||||
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list from a different session was refused: %v", err)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-old" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
|
||||
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
|
||||
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
|
||||
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["session"] != "sess-new" || out["open"] != 1 {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
took, _ := out["took-over-from"].(map[string]any)
|
||||
if took == nil || took["session"] != "sess-old" {
|
||||
t.Fatalf("the answer did not name the earlier id: %v", out)
|
||||
}
|
||||
|
||||
oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old"))
|
||||
if err != nil || len(oldFile.Waits) != 0 {
|
||||
t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id {
|
||||
t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err)
|
||||
}
|
||||
|
||||
// The new session can act on the name at once: no 10-minute rule.
|
||||
if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("the new session could not act right after adopting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
|
||||
// no bound protects it.
|
||||
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
|
||||
// sess-fresh calls again right before being adopted away from.
|
||||
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("adopt was refused even though it always takes over: %v", err)
|
||||
}
|
||||
if out["session"] != "sess-taker" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
|
||||
// another session, naming it.
|
||||
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0.Add(2*time.Minute))
|
||||
if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") {
|
||||
t.Fatalf("the earlier id's next call did not name the new session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
|
||||
// hook to read directly.
|
||||
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
|
||||
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
|
||||
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
|
||||
store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx, err := readNodeIndex(nodeIndexPath(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
|
||||
idx.Names[0].CurrentSession != "sess-y" {
|
||||
t.Fatalf("%+v", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// wantsOnlyWait is the id of the one wait a fixture added, read back through list.
|
||||
func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string {
|
||||
t.Helper()
|
||||
out, err := ListWaits(p, session, name+"@"+p.Node, store, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := out["open"].([]map[string]any)
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("%v", open)
|
||||
}
|
||||
return open[0]["id"].(string)
|
||||
}
|
||||
|
||||
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
|
||||
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
|
||||
if isErr || !strings.Contains(text, "sess-old") {
|
||||
t.Fatalf("adopt: %v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
|
||||
|
||||
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
|
||||
// names' waits in its one mirrored file, not only the name an op last acted on.
|
||||
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, w := range f.Waits {
|
||||
ids[w.ID] = true
|
||||
}
|
||||
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
|
||||
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
|
||||
// file holding the other name's waits — it is never emptied wholesale.
|
||||
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
||||
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
||||
|
||||
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
||||
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
|
||||
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
|
||||
}
|
||||
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
|
||||
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
|
||||
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
|
||||
// fresh three-strikes count starts under the new binding.
|
||||
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
||||
waitsDir := WaitsDirPath(p)
|
||||
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
|
||||
t.Errorf("sess-old's counter was not cleared: %v", c)
|
||||
}
|
||||
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
|
||||
t.Errorf("sess-new's counter was not cleared: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
|
||||
// mismatched session; it answers the name's waits and who currently holds it.
|
||||
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
by := "fix-1629@g14"
|
||||
|
||||
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
||||
// succeeds, as the first caller for an unbound name).
|
||||
out, err := ListWaits(p, "sess-reader", by, store, t0)
|
||||
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("an unbound name: %v %v", out, err)
|
||||
}
|
||||
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
|
||||
t.Fatal("list bound an unbound name")
|
||||
}
|
||||
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
||||
Bound: 10}, store, t0); err != nil {
|
||||
t.Fatalf("add after a read-only list: %v", err)
|
||||
}
|
||||
|
||||
// A name bound to another session: list still answers (never refused), and says who holds it.
|
||||
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("list for a name held by another session was refused: %v", err)
|
||||
}
|
||||
if len(out["open"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("list did not answer the name's waits: %v", out)
|
||||
}
|
||||
held, _ := out["held-by"].(map[string]any)
|
||||
if held == nil || held["session"] != "sess-first" {
|
||||
t.Fatalf("list did not say who holds the name: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
|
||||
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
|
||||
nw, _, found, err := readNamedWaits(store, key)
|
||||
return nw, found, err
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory"
|
||||
"memory",
|
||||
"waits"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -67,6 +68,7 @@
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_wait",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
|
||||
@@ -5,16 +5,16 @@ machine: an agent working on a change builds it and runs its tests there, with t
|
||||
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
|
||||
scope, and is not where development happens.
|
||||
|
||||
Assign it to every node.
|
||||
Assign it to every node, with the `go` and `nodejs` modules: it requires their provisions `go-toolchain` and `node-toolchain` (novox/hq ADR 0477 clause 4).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | package | class (ADR 0182) |
|
||||
|---|---|---|
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
|
||||
| the Go toolchain, for `go build` and `go test` in every Go module | the `go` module's package `go` | package |
|
||||
| a C compiler, for `go test -race` and cgo | `gcc` | package |
|
||||
| make | `make` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
|
||||
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | the `nodejs` module's packages `nodejs`, `npm` | package |
|
||||
| Python, for the scripts some checks run | `python` | package |
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"go-toolchain",
|
||||
"node-toolchain"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "c-compiler",
|
||||
"type": "package",
|
||||
@@ -20,16 +19,6 @@
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A unified diff applied by the tool itself (novox/hq issue 1604). The forge's diffpatch answers 500 with an empty
|
||||
// message when an unchanged context line holds a non-ASCII character, so a patch the forge did not take is applied
|
||||
// here: each touched file is read at the commit the commit goes on, its hunks are applied, and the resulting
|
||||
// files are committed through the same call a files commit uses. The forge's files call carries no file mode, so
|
||||
// a patch that creates an executable file, changes a mode or touches a symlink is not applied here: the tool says so.
|
||||
|
||||
// fileSource reads a file at the commit the patch is applied to; ok is false when there is no such file.
|
||||
type fileSource func(path string) (content []byte, mode string, ok bool, err error)
|
||||
|
||||
// patchFile is one file section of a patch.
|
||||
type patchFile struct {
|
||||
oldPath, newPath string // "" when /dev/null
|
||||
create, remove bool
|
||||
renamed bool
|
||||
oldSeen bool
|
||||
hunks []hunk
|
||||
modeBlock string // why the files call cannot carry this section, "" when it can
|
||||
}
|
||||
|
||||
type hunk struct {
|
||||
oldStart int
|
||||
old, new []string
|
||||
oldNoNL, newNoNL bool
|
||||
leading int // unchanged lines before the first change
|
||||
trailing int // unchanged lines after the last change
|
||||
}
|
||||
|
||||
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
||||
|
||||
// gitPath reads a path of a diff header: git quotes one holding unusual bytes in C style.
|
||||
func gitPath(s string) string {
|
||||
s = strings.TrimRight(s, "\t ")
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
if u, err := strconv.Unquote(s); err == nil {
|
||||
return u
|
||||
}
|
||||
} else if i := strings.IndexByte(s, '\t'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripPrefix(p string) string {
|
||||
if p == "/dev/null" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 && (p[:i] == "a" || p[:i] == "b") {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nonRegular(mode string) bool { return mode != "" && mode != "100644" }
|
||||
|
||||
// parsePatch reads a unified diff into its file sections.
|
||||
func parsePatch(text string) ([]*patchFile, error) {
|
||||
lines := strings.Split(text, "\n")
|
||||
if n := len(lines); n > 0 && lines[n-1] == "" {
|
||||
lines = lines[:n-1]
|
||||
}
|
||||
var files []*patchFile
|
||||
var cur *patchFile
|
||||
block := func(f *patchFile, what string) {
|
||||
if f.modeBlock == "" {
|
||||
f.modeBlock = what
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(lines); i++ {
|
||||
l := strings.TrimSuffix(lines[i], "\r")
|
||||
switch {
|
||||
case strings.HasPrefix(l, "diff --git "):
|
||||
cur = &patchFile{}
|
||||
files = append(files, cur)
|
||||
rest := strings.TrimPrefix(l, "diff --git ")
|
||||
if k := strings.Index(rest, " b/"); k > 0 && !strings.HasPrefix(rest, `"`) {
|
||||
cur.oldPath, cur.newPath = stripPrefix(rest[:k]), stripPrefix(rest[k+1:])
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "new file mode "):
|
||||
cur.create = true
|
||||
if m := strings.TrimPrefix(l, "new file mode "); nonRegular(m) {
|
||||
block(cur, "creates a file of mode "+m)
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "deleted file mode "):
|
||||
cur.remove = true
|
||||
case cur != nil && strings.HasPrefix(l, "old mode "):
|
||||
block(cur, "changes the file mode ("+l+")")
|
||||
case cur != nil && strings.HasPrefix(l, "new mode "):
|
||||
block(cur, "changes the file mode ("+l+")")
|
||||
case cur != nil && strings.HasPrefix(l, "index "):
|
||||
if f := strings.Fields(l); len(f) == 3 && nonRegular(f[2]) {
|
||||
block(cur, "touches a file of mode "+f[2])
|
||||
}
|
||||
case cur != nil && strings.HasPrefix(l, "rename from "):
|
||||
cur.oldPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename from ")), true
|
||||
case cur != nil && strings.HasPrefix(l, "rename to "):
|
||||
cur.newPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename to ")), true
|
||||
case strings.HasPrefix(l, "copy from "), strings.HasPrefix(l, "copy to "):
|
||||
return nil, fmt.Errorf("a copy in a patch (%s) is not applied by the tool", l)
|
||||
case strings.HasPrefix(l, "GIT binary patch"), strings.HasPrefix(l, "Binary files "):
|
||||
return nil, fmt.Errorf("a binary patch is not applied by the tool")
|
||||
case strings.HasPrefix(l, "--- "):
|
||||
if cur == nil || cur.oldSeen {
|
||||
cur = &patchFile{}
|
||||
files = append(files, cur)
|
||||
}
|
||||
p := stripPrefix(gitPath(strings.TrimPrefix(l, "--- ")))
|
||||
cur.oldPath = p
|
||||
if p == "" {
|
||||
cur.create = true
|
||||
}
|
||||
cur.oldSeen = true
|
||||
if i+1 >= len(lines) || !strings.HasPrefix(lines[i+1], "+++ ") {
|
||||
return nil, fmt.Errorf("the line %d, %q, is not followed by a +++ line", i+1, l)
|
||||
}
|
||||
i++
|
||||
q := stripPrefix(gitPath(strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "+++ ")))
|
||||
cur.newPath = q
|
||||
if q == "" {
|
||||
cur.remove = true
|
||||
}
|
||||
case cur != nil && hunkHeader.MatchString(l):
|
||||
m := hunkHeader.FindStringSubmatch(l)
|
||||
atoi := func(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
h := hunk{oldStart: atoi(m[1], 0)}
|
||||
oldN, newN := atoi(m[2], 1), atoi(m[4], 1)
|
||||
startLine := i + 1
|
||||
noNL := func() { // "\ No newline at end of file" is about the line before it
|
||||
prev := lines[i-1]
|
||||
switch {
|
||||
case prev != "" && prev[0] == '-':
|
||||
h.oldNoNL = true
|
||||
case prev != "" && prev[0] == '+':
|
||||
h.newNoNL = true
|
||||
default:
|
||||
h.oldNoNL, h.newNoNL = true, true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for oldN > 0 || newN > 0 {
|
||||
i++
|
||||
if i >= len(lines) {
|
||||
return nil, fmt.Errorf("the hunk of %s at patch line %d ends before its %d old and %d new lines are all there", cur.name(), startLine, oldN, newN)
|
||||
}
|
||||
b := strings.TrimSuffix(lines[i], "\r")
|
||||
if b == "" {
|
||||
b = " " // a blank context line some tools strip to nothing, or a bare CR
|
||||
}
|
||||
switch b[0] {
|
||||
case '\\':
|
||||
noNL()
|
||||
case ' ':
|
||||
text := lines[i]
|
||||
if strings.HasPrefix(text, " ") {
|
||||
text = text[1:] // a bare "" or "\r" is the whole blank line, its leading space stripped
|
||||
}
|
||||
h.old, h.new = append(h.old, text), append(h.new, text)
|
||||
oldN--
|
||||
newN--
|
||||
if changed {
|
||||
h.trailing++
|
||||
} else {
|
||||
h.leading++
|
||||
}
|
||||
case '-':
|
||||
h.old = append(h.old, lines[i][1:])
|
||||
oldN--
|
||||
changed, h.trailing = true, 0
|
||||
case '+':
|
||||
h.new = append(h.new, lines[i][1:])
|
||||
newN--
|
||||
changed, h.trailing = true, 0
|
||||
default:
|
||||
return nil, fmt.Errorf("the hunk of %s at patch line %d has a line %d that is neither context, - nor +: %q", cur.name(), startLine, i+1, lines[i])
|
||||
}
|
||||
}
|
||||
for i+1 < len(lines) && strings.HasPrefix(lines[i+1], `\`) {
|
||||
i++
|
||||
noNL()
|
||||
}
|
||||
cur.hunks = append(cur.hunks, h)
|
||||
}
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.newPath == "" && f.oldPath == "" {
|
||||
return nil, fmt.Errorf("a file section of the patch names no file")
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("the patch holds no file section")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *patchFile) name() string {
|
||||
if f.newPath != "" {
|
||||
return f.newPath
|
||||
}
|
||||
return f.oldPath
|
||||
}
|
||||
|
||||
// textFile is a file's content as lines, and whether its last line ends with a newline.
|
||||
type textFile struct {
|
||||
lines []string
|
||||
endNL bool
|
||||
}
|
||||
|
||||
func splitText(b []byte) textFile {
|
||||
if len(b) == 0 {
|
||||
return textFile{endNL: true}
|
||||
}
|
||||
s := string(b)
|
||||
endNL := strings.HasSuffix(s, "\n")
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
return textFile{lines: strings.Split(s, "\n"), endNL: endNL}
|
||||
}
|
||||
|
||||
func (t textFile) bytes() []byte {
|
||||
if len(t.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.Join(t.lines, "\n")
|
||||
if t.endNL {
|
||||
s += "\n"
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// apply puts one hunk on the file, at where the hunk says or, as git apply does, at the nearest place its old
|
||||
// lines are found exactly; delta is how far earlier hunks of this file moved lines.
|
||||
func (t textFile) apply(h hunk, delta int) (textFile, int, error) {
|
||||
want := h.oldStart - 1 + delta
|
||||
if len(h.old) == 0 && h.oldStart > 0 {
|
||||
want = h.oldStart + delta // a pure insertion is "after" the line it names
|
||||
}
|
||||
if want < 0 {
|
||||
want = 0
|
||||
}
|
||||
matches := func(at int) bool {
|
||||
if at < 0 || at+len(h.old) > len(t.lines) {
|
||||
return false
|
||||
}
|
||||
for k, l := range h.old {
|
||||
if t.lines[at+k] != l {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// the old side's last line lacks a newline only when the file's does
|
||||
if at+len(h.old) == len(t.lines) && len(h.old) > 0 && h.oldNoNL == t.endNL {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// As git apply does: a hunk that starts at line 1 (or 0) begins the file, and a hunk with context but none
|
||||
// after its last change ends it; a hunk whose old side lacks the final newline ends it too.
|
||||
atStart := h.oldStart <= 1
|
||||
atEnd := (h.trailing == 0 && h.leading > 0) || h.oldNoNL
|
||||
at := -1
|
||||
switch {
|
||||
case atStart && atEnd:
|
||||
if len(t.lines) == len(h.old) && matches(0) {
|
||||
at = 0
|
||||
}
|
||||
case atStart:
|
||||
if matches(0) {
|
||||
at = 0
|
||||
}
|
||||
case atEnd:
|
||||
if e := len(t.lines) - len(h.old); matches(e) {
|
||||
at = e
|
||||
}
|
||||
}
|
||||
for d := 0; !atStart && !atEnd && d <= len(t.lines)+1 && at < 0; d++ {
|
||||
switch {
|
||||
case matches(want - d):
|
||||
at = want - d
|
||||
case matches(want + d):
|
||||
at = want + d
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return t, delta, fmt.Errorf("the hunk at line %d does not apply: its context and removed lines are not in the file (first of them: %q)", h.oldStart, firstOf(h.old))
|
||||
}
|
||||
out := append(append(append([]string{}, t.lines[:at]...), h.new...), t.lines[at+len(h.old):]...)
|
||||
endNL := t.endNL
|
||||
if at+len(h.old) == len(t.lines) && (len(h.new) > 0 || len(h.old) > 0) {
|
||||
endNL = !h.newNoNL
|
||||
}
|
||||
return textFile{lines: out, endNL: endNL}, delta + len(h.new) - len(h.old), nil
|
||||
}
|
||||
|
||||
func firstOf(l []string) string {
|
||||
if len(l) == 0 {
|
||||
return ""
|
||||
}
|
||||
return l[0]
|
||||
}
|
||||
|
||||
// patchAsFiles applies the patch to the files read through read, and says the files to commit.
|
||||
func patchAsFiles(text string, read fileSource) ([]FileChange, error) {
|
||||
sections, err := parsePatch(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range sections {
|
||||
if s.modeBlock != "" {
|
||||
return nil, fmt.Errorf("%s %s, and the forge's files call carries no file mode", s.name(), s.modeBlock)
|
||||
}
|
||||
}
|
||||
work := map[string][]byte{} // the files as they are being changed; nil means absent
|
||||
orig := map[string]bool{} // which paths existed
|
||||
load := func(p string) error {
|
||||
if _, seen := work[p]; seen {
|
||||
return nil
|
||||
}
|
||||
b, mode, ok, err := read(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && mode != "" && mode != "100644" {
|
||||
return fmt.Errorf("%s is a file of mode %s, and the forge's files call carries no file mode, so committing it that way would make it 100644", p, mode)
|
||||
}
|
||||
orig[p] = ok
|
||||
if ok {
|
||||
if b == nil {
|
||||
b = []byte{}
|
||||
}
|
||||
work[p] = b
|
||||
} else {
|
||||
work[p] = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, s := range sections {
|
||||
from, to := s.oldPath, s.newPath
|
||||
if s.create {
|
||||
from = ""
|
||||
}
|
||||
var t textFile
|
||||
if from != "" {
|
||||
if err := load(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if work[from] == nil {
|
||||
return nil, fmt.Errorf("%s: the file is not there at the commit the patch goes on, so the patch does not apply", from)
|
||||
}
|
||||
if !utf8.Valid(work[from]) && len(s.hunks) > 0 {
|
||||
return nil, fmt.Errorf("%s is not UTF-8 text: the tool applies text patches only", from)
|
||||
}
|
||||
t = splitText(work[from])
|
||||
} else {
|
||||
if err := load(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if work[to] != nil {
|
||||
return nil, fmt.Errorf("%s: the patch creates it, but it is already there at the commit the patch goes on", to)
|
||||
}
|
||||
t = textFile{endNL: true}
|
||||
}
|
||||
delta := 0
|
||||
for _, h := range s.hunks {
|
||||
if t, delta, err = t.apply(h, delta); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", s.name(), err)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.remove:
|
||||
if len(t.lines) != 0 {
|
||||
return nil, fmt.Errorf("%s: the patch deletes the file, but lines are left after its hunks", from)
|
||||
}
|
||||
work[from] = nil
|
||||
default:
|
||||
if s.renamed && from != to {
|
||||
work[from] = nil
|
||||
if err := load(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
body := t.bytes()
|
||||
if body == nil {
|
||||
body = []byte{}
|
||||
}
|
||||
work[to] = body
|
||||
}
|
||||
}
|
||||
var paths []string
|
||||
for p := range work {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var out []FileChange
|
||||
for _, p := range paths {
|
||||
switch {
|
||||
case work[p] == nil && !orig[p]:
|
||||
case work[p] == nil:
|
||||
out = append(out, FileChange{Path: p, Delete: true})
|
||||
default:
|
||||
fc := FileChange{Path: p, size: len(work[p])}
|
||||
if utf8.Valid(work[p]) {
|
||||
s := string(work[p])
|
||||
fc.Content = &s
|
||||
} else {
|
||||
s := base64Std(work[p])
|
||||
fc.ContentBase64 = &s
|
||||
}
|
||||
out = append(out, fc)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("the patch changes no file")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func base64Std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 1604: the forge's diffpatch answers 500 when an unchanged context line holds a non-ASCII
|
||||
// character; the tool then applies the patch itself.
|
||||
|
||||
func sectionFake(t *testing.T) *fakeForge {
|
||||
f := newFake(t)
|
||||
f.trees["c0"]["doc.md"] = "# Title\n\nSee § 4 for the rule.\nold line\nend § here\n"
|
||||
f.trees["c0"]["run.sh"] = "#!/bin/sh\necho a\n"
|
||||
f.patchStatus = 500
|
||||
return f
|
||||
}
|
||||
|
||||
const sectionPatch = "diff --git a/doc.md b/doc.md\n--- a/doc.md\n+++ b/doc.md\n@@ -2,4 +2,4 @@\n \n See § 4 for the rule.\n-old line\n+new line\n end § here\n"
|
||||
|
||||
func TestAPatchWhoseContextHoldsANonASCIICharacterIsAppliedByTheToolWhenTheForgeAnswers500(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "# Title\n\nSee § 4 for the rule.\nnew line\nend § here\n"
|
||||
if tree := f.trees[got.Commit]; tree["doc.md"] != want || tree["README.md"] != "hello\n" {
|
||||
t.Fatalf("tree %v", tree)
|
||||
}
|
||||
if len(f.patches) != 1 || len(f.writes) != 1 || f.writes[0]["new_branch"] != "fix/1604" || f.writes[0]["branch"] != "main" {
|
||||
t.Fatalf("patches %v writes %v", f.patches, f.writes)
|
||||
}
|
||||
if len(got.Files) != 1 || got.Files[0].Operation != "update" || got.Parent != "c0" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Said, " "), "applied it") {
|
||||
t.Errorf("not said: %v", got.Said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchTheForgeTakesIsStillTheForgesToApply(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.patchStatus = 0
|
||||
if _, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("the tool applied a patch the forge took")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchThatCreatesDeletesAndRenamesIsApplied(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
patch := "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+one §\n+two\n" +
|
||||
"diff --git a/docs/a.md b/docs/a.md\ndeleted file mode 100644\n--- a/docs/a.md\n+++ /dev/null\n@@ -1 +0,0 @@\n-a\n" +
|
||||
"diff --git a/README.md b/README.txt\nsimilarity index 80%\nrename from README.md\nrename to README.txt\n--- a/README.md\n+++ b/README.txt\n@@ -1 +1 @@\n-hello\n+hello §\n"
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/r", "patch": patch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := f.trees[got.Commit]
|
||||
if tree["new.txt"] != "one §\ntwo\n" || tree["README.txt"] != "hello §\n" {
|
||||
t.Fatalf("tree %v", tree)
|
||||
}
|
||||
if _, has := tree["README.md"]; has {
|
||||
t.Error("the renamed file is still there")
|
||||
}
|
||||
if _, has := tree["docs/a.md"]; has {
|
||||
t.Error("the deleted file is still there")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHunkMovedByEarlierLinesIsFoundAndNoNewlineIsKept(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.trees["c0"]["x.txt"] = "pre\npre\nabc §\nd\ne"
|
||||
// the hunk below starts at line 2, though its lines are at 3: the tool finds them one line on
|
||||
patch := "--- a/x.txt\n+++ b/x.txt\n@@ -2,3 +2,3 @@\n abc §\n d\n-e\n\\ No newline at end of file\n+f\n\\ No newline at end of file\n"
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/n", "patch": patch}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := f.trees[got.Commit]["x.txt"]; g != "pre\npre\nabc §\nd\nf" {
|
||||
t.Fatalf("%q", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchThatDoesNotApplyIsRefusedNamingTheFileAndLine(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
patch := strings.Replace(sectionPatch, "-old line", "-other line", 1)
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "doc.md") || !strings.Contains(err.Error(), "at line 2 does not apply") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("something was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchWithAnExecutableFileIsNotAppliedByTheToolAndSaysWhy(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
for _, patch := range []string{
|
||||
"diff --git a/s.sh b/s.sh\nnew file mode 100755\n--- /dev/null\n+++ b/s.sh\n@@ -0,0 +1 @@\n+echo §\n",
|
||||
"diff --git a/run.sh b/run.sh\nold mode 100644\nnew mode 100755\n",
|
||||
"diff --git a/run.sh b/run.sh\nindex 1111111..2222222 100755\n--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
|
||||
} {
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/x", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "the forge did not take the patch") || !strings.Contains(err.Error(), "carries no file mode") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
if len(f.writes) != 0 {
|
||||
t.Fatal("something was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClientErrorOfTheForgeIsNotRetriedByTheTool(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
f.patchStatus = 422
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
|
||||
if err == nil || len(f.writes) != 0 {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADefaultBranchIsStillRefusedForAPatch(t *testing.T) {
|
||||
f := sectionFake(t)
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "main", "patch": sectionPatch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0266") || len(f.patches)+len(f.writes) != 0 {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1.
|
||||
|
||||
func appliedTo(t *testing.T, content, patch string) (string, error) {
|
||||
t.Helper()
|
||||
f := sectionFake(t)
|
||||
f.trees["c0"]["y.txt"] = content
|
||||
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/y", "patch": patch}))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.trees[got.Commit]["y.txt"], nil
|
||||
}
|
||||
|
||||
func TestAHunkAtLineOneMustMatchAtTheStartOfTheFile(t *testing.T) {
|
||||
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,3 @@\n-a\n+A\n b\n c\n"
|
||||
if got, err := appliedTo(t, "X\nb\nc\nzzz\na\nb\nc\n", patch); err == nil {
|
||||
t.Fatalf("applied in the middle: %q", got)
|
||||
}
|
||||
if got, err := appliedTo(t, "a\nb\nc\nzzz\n", patch); err != nil || got != "A\nb\nc\nzzz\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHunkWithNoTrailingContextMustMatchAtTheEndOfTheFile(t *testing.T) {
|
||||
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,4 @@\n a\n b\n c\n+NEW\n"
|
||||
if got, err := appliedTo(t, "a\nb\nc\nd\ne\n", patch); err == nil {
|
||||
t.Fatalf("added in the middle: %q", got)
|
||||
}
|
||||
patch = "--- a/y.txt\n+++ b/y.txt\n@@ -3,3 +3,4 @@\n a\n b\n c\n+NEW\n"
|
||||
if got, err := appliedTo(t, "x\ny\na\nb\nc\n", patch); err != nil || got != "x\ny\na\nb\nc\nNEW\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExistingExecutableFileIsNotSilentlyMadeNonExecutable(t *testing.T) {
|
||||
for _, patch := range []string{
|
||||
"diff --git a/run.sh b/run.sh2\nsimilarity index 100%\nrename from run.sh\nrename to run.sh2\n",
|
||||
"--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
|
||||
} {
|
||||
f := sectionFake(t)
|
||||
f.modes = map[string]string{"run.sh": "100755"}
|
||||
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/m", "patch": patch}))
|
||||
if err == nil || !strings.Contains(err.Error(), "100755") || len(f.writes) != 0 {
|
||||
t.Errorf("%v (%d writes)", err, len(f.writes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPatchWithCRLFLinesAndABareCRContextLineDoesNotPanic(t *testing.T) {
|
||||
patch := "--- a/y.txt\r\n+++ b/y.txt\r\n@@ -1,3 +1,3 @@\r\n a\r\n\r\n-c\r\n+C\r\n"
|
||||
got, err := appliedTo(t, "a\r\n\r\nc\r\n", patch)
|
||||
if err != nil || got != "a\r\n\r\nC\r\n" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -326,14 +326,49 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
var made struct {
|
||||
Commit madeCommit `json:"commit"`
|
||||
}
|
||||
// A patch goes to the forge's diffpatch first. When the forge answers a server error (it does so, with an
|
||||
// empty message, when an unchanged context line holds a non-ASCII character: novox/hq issue 1604) the tool
|
||||
// applies the patch itself and commits the resulting files the way a files commit does.
|
||||
files := a.Files
|
||||
forgeApplied := false
|
||||
if a.Patch != "" {
|
||||
on["content"] = a.Patch
|
||||
if err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made); err != nil {
|
||||
err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made)
|
||||
var status *forgehttp.StatusError
|
||||
switch {
|
||||
case err == nil:
|
||||
forgeApplied = true
|
||||
case errors.As(err, &status) && status.Status >= 500:
|
||||
delete(on, "content")
|
||||
read := func(p string) ([]byte, string, bool, error) {
|
||||
b, ok, err := f.contentAt(ctx, repo, p, ref)
|
||||
if err != nil || !ok {
|
||||
return nil, "", false, err
|
||||
}
|
||||
mode, err := f.modeAt(ctx, repo, ref, p)
|
||||
return b, mode, true, err
|
||||
}
|
||||
var perr error
|
||||
if files, perr = patchAsFiles(a.Patch, read); perr != nil {
|
||||
return nil, fmt.Errorf("the forge did not take the patch: %w; the tool could not apply it itself: %v", err, perr)
|
||||
}
|
||||
total := 0
|
||||
for _, fc := range files {
|
||||
if fc.size > maxFileBytes {
|
||||
return nil, refuse("%s would be %s, more than the %s one file of a commit may be. Nothing was sent", fc.Path, mib(fc.size), mib(maxFileBytes))
|
||||
}
|
||||
total += fc.size
|
||||
}
|
||||
if total > maxCommitBytes {
|
||||
return nil, refuse("the patch's files are %s together, more than the %s one commit carries. Nothing was sent", mib(total), mib(maxCommitBytes))
|
||||
}
|
||||
out.Said = append(out.Said, fmt.Sprintf("the forge answered %d to the patch, so the tool applied it to the files at %s and committed them", status.Status, short(ref)))
|
||||
default:
|
||||
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
|
||||
}
|
||||
} else {
|
||||
files := a.Files
|
||||
for _, fc := range a.Files {
|
||||
}
|
||||
if !forgeApplied {
|
||||
for _, fc := range files {
|
||||
if fc.CopyFrom != nil {
|
||||
// Read, and judged against the limits, before anything is sent.
|
||||
var err error
|
||||
@@ -391,7 +426,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
moved = "base " + out.Base
|
||||
}
|
||||
how := "each file it changes was checked against " + short(read) + " by its blob, so none of them changed in between"
|
||||
if a.Patch != "" {
|
||||
if forgeApplied {
|
||||
how = "the forge applied the patch there, and it refuses a patch whose context changed"
|
||||
}
|
||||
out.Said = append(out.Said, fmt.Sprintf("the %s moved from %s to %s between the read and the commit, so the "+
|
||||
@@ -478,3 +513,82 @@ func short(sha string) string {
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// contentAt is the content of a file at a ref; ok is false when there is no file there.
|
||||
func (f *Forge) contentAt(ctx context.Context, repo, path, ref string) ([]byte, bool, error) {
|
||||
seg, err := segments("file path", path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
body, err := f.Do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var entry struct {
|
||||
Type string `json:"type"`
|
||||
Size int `json:"size"`
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
if err := jsonInto(body, &entry); err != nil {
|
||||
return nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
|
||||
}
|
||||
if entry.Type != "file" {
|
||||
return nil, false, refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
|
||||
}
|
||||
if entry.Encoding == "base64" && (entry.Content != "" || entry.Size == 0) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(entry.Content, "\n", ""))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the content of %s is not base64: %w", path, err)
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
raw, err := f.Do(ctx, http.MethodGet, repo+"/raw/"+seg+"?ref="+queryEscape(ref), nil, maxFileBytes+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// modeAt is the git mode of a file at a commit, read through the git trees API: the contents API carries none.
|
||||
func (f *Forge) modeAt(ctx context.Context, repo, ref, path string) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
tree := ref
|
||||
for depth, name := range parts {
|
||||
found := false
|
||||
for page := 1; ; page++ {
|
||||
var got struct {
|
||||
Tree []struct {
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode"`
|
||||
Type string `json:"type"`
|
||||
SHA string `json:"sha"`
|
||||
} `json:"tree"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
if err := f.GetJSON(ctx, fmt.Sprintf("%s/git/trees/%s?per_page=1000&page=%d", repo, url.PathEscape(tree), page), &got); err != nil {
|
||||
return "", fmt.Errorf("the mode of %s could not be read: %w", path, err)
|
||||
}
|
||||
for _, e := range got.Tree {
|
||||
if e.Path != name {
|
||||
continue
|
||||
}
|
||||
if depth == len(parts)-1 {
|
||||
return e.Mode, nil
|
||||
}
|
||||
tree, found = e.SHA, true
|
||||
break
|
||||
}
|
||||
if found || len(got.Tree) == 0 || page*1000 >= got.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && depth < len(parts)-1 {
|
||||
return "", fmt.Errorf("the mode of %s could not be read: %s is not a folder at %s", path, name, short(ref))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("the mode of %s could not be read: not in the tree of %s", path, short(ref))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ type fakeForge struct {
|
||||
parentsBroken bool // and GET git/commits fails
|
||||
parents map[string]string // commit -> parent, as GET git/commits says it
|
||||
sources map[string]*sourceRepo
|
||||
modes map[string]string // path -> git mode when not 100644
|
||||
patchStatus int // the status diffpatch answers instead of taking the patch (issue 1604)
|
||||
}
|
||||
|
||||
type move struct {
|
||||
@@ -128,7 +130,48 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
reply(map[string]any{"type": "file", "path": p, "sha": blob(content)})
|
||||
reply(map[string]any{"type": "file", "path": p, "sha": blob(content), "size": len(content), "encoding": "base64",
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content))})
|
||||
case r.Method == "GET" && strings.HasPrefix(path, repo+"/git/trees/"):
|
||||
id := strings.TrimPrefix(path, repo+"/git/trees/")
|
||||
commitID, dir := id, ""
|
||||
if strings.HasPrefix(id, "tree:") {
|
||||
parts := strings.SplitN(id, ":", 3)
|
||||
commitID, dir = parts[1], parts[2]
|
||||
}
|
||||
if c, isBranch := f.branches[commitID]; isBranch {
|
||||
commitID = c
|
||||
}
|
||||
var entries []map[string]any
|
||||
seen := map[string]bool{}
|
||||
for p := range f.trees[commitID] {
|
||||
if dir != "" {
|
||||
if !strings.HasPrefix(p, dir+"/") {
|
||||
continue
|
||||
}
|
||||
p = strings.TrimPrefix(p, dir+"/")
|
||||
}
|
||||
name, rest, nested := strings.Cut(p, "/")
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
full := name
|
||||
if dir != "" {
|
||||
full = dir + "/" + name
|
||||
}
|
||||
if nested {
|
||||
_ = rest
|
||||
entries = append(entries, map[string]any{"path": name, "mode": "040000", "type": "tree", "sha": "tree:" + commitID + ":" + full})
|
||||
continue
|
||||
}
|
||||
mode := "100644"
|
||||
if m, ok := f.modes[full]; ok {
|
||||
mode = m
|
||||
}
|
||||
entries = append(entries, map[string]any{"path": name, "mode": mode, "type": "blob", "sha": blob(f.trees[commitID][full])})
|
||||
}
|
||||
reply(map[string]any{"tree": entries, "total_count": len(entries)})
|
||||
case r.Method == "POST" && path == repo+"/contents":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
@@ -176,6 +219,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.patches = append(f.patches, body)
|
||||
if f.patchStatus != 0 {
|
||||
http.Error(w, `{"message":""}`, f.patchStatus)
|
||||
return
|
||||
}
|
||||
f.next++
|
||||
made := fmt.Sprintf("p%d", f.next)
|
||||
from := f.branches[body["branch"].(string)]
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
module gitea
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.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.18.5 // 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.15 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
+12
-14
@@ -1,29 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/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.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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,11 @@
|
||||
# go
|
||||
|
||||
The Go toolchain as a module (novox/hq ADR 0477 clause 4, issue 1573).
|
||||
|
||||
- **The only module that declares the package `go`.** `checks/toolchains` refuses any other that does.
|
||||
- **Provides `go-toolchain` with the machine's reach**: dev-tools, lab and mesh-build-go require it, so each
|
||||
composes only on a machine where this module is assigned. It is never pulled in for whatever asked, so `go` is
|
||||
assigned to a node on purpose, and a node without it refuses those modules at resolution.
|
||||
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
|
||||
|
||||
The node-engine never uninstalls a package, so moving the declaration here removed nothing from a node.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"module": "go",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "go-toolchain",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
module keycloak
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# lab
|
||||
|
||||
The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `npm`; installs the package `go`; installs the package `incus`.
|
||||
The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `incus`; requires `go-toolchain` and `node-toolchain`, so the `go` module and the `nodejs` module are assigned to a machine running it (novox/hq ADR 0477 clause 4).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
|
||||
+3
-11
@@ -6,7 +6,9 @@
|
||||
"virtualisation"
|
||||
],
|
||||
"requires": [
|
||||
"npm-package-registry"
|
||||
"npm-package-registry",
|
||||
"go-toolchain",
|
||||
"node-toolchain"
|
||||
],
|
||||
"contributes": {
|
||||
"npm-package-registry": {
|
||||
@@ -84,16 +86,6 @@
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "incus",
|
||||
"type": "package",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// What the autoconfiguration module (automx2) holds, as stored: its provider, server, domain and server_domain
|
||||
// rows, read from its SQLite file inside its container with the sqlite3 program the image carries, opened
|
||||
// read-only. Nothing is written and nothing is interpreted: the rows are the evidence for what automx answers
|
||||
// to mail clients (novox/hq issue 1630, ADR 0480), and the reason the answer may be empty.
|
||||
|
||||
import type { Exec } from "./health.js";
|
||||
|
||||
export const AUTOMX_DATABASE = "/data/db.sqlite";
|
||||
|
||||
/** The tables read, each with its columns and its ordering. Fixed text: the tool takes no query from its caller. */
|
||||
export const AUTOMX_TABLES: Record<string, { columns: string[]; order: string }> = {
|
||||
provider: { columns: ["id", "name", "short_name"], order: "id" },
|
||||
server: { columns: ["id", "prio", "name", "port", "type", "socket_type", "user_name", "authentication"], order: "id" },
|
||||
domain: { columns: ["id", "name", "provider_id", "ldapserver_id"], order: "id" },
|
||||
server_domain: { columns: ["server_id", "domain_id"], order: "domain_id, server_id" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The one statement that reads a table. The sqlite3 in the mailu-automx image does not know `-json` (it answers
|
||||
* "sqlite3: Error: unknown option: -json", issue 1630), so each column is printed as `<type>:<hex of its bytes>`, which
|
||||
* the oldest sqlite3 prints in its default list mode: no quoting, no separator or newline inside a cell, and a NULL
|
||||
* (`null:`) apart from an empty text (`text:`).
|
||||
*/
|
||||
export function selectOf(table: string): string {
|
||||
const t = AUTOMX_TABLES[table];
|
||||
return `SELECT ${t.columns.map((c) => `typeof(${c}) || ':' || hex(${c})`).join(", ")} FROM ${table} ORDER BY ${t.order}`;
|
||||
}
|
||||
|
||||
export type Row = Record<string, string | number | null>;
|
||||
|
||||
export type AutomxRows = {
|
||||
database: string;
|
||||
tables: Record<string, Row[]>;
|
||||
/** servers whose name is empty: the client is told an empty host name for them */
|
||||
empty_names: { id: number; type: string }[];
|
||||
/** tables that could not be read, each with what sqlite3 or docker said on one line */
|
||||
unreadable: Record<string, string>;
|
||||
};
|
||||
|
||||
/** One cell as selectOf prints it: `<type>:<hex>`; text is the bytes read as UTF-8, a number is read from its text. */
|
||||
function cellOf(cell: string): string | number | null {
|
||||
const at = cell.indexOf(":");
|
||||
if (at < 0 || cell.length - at - 1 > 0 && !/^([0-9A-Fa-f]{2})+$/.test(cell.slice(at + 1))) throw new Error(`sqlite3 printed ${JSON.stringify(cell.slice(0, 40))}, not a cell`);
|
||||
const type = cell.slice(0, at);
|
||||
const bytes = Buffer.from(cell.slice(at + 1), "hex").toString("utf8");
|
||||
switch (type) {
|
||||
case "null": return null;
|
||||
case "integer": case "real": return Number(bytes);
|
||||
default: return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/** sqlite3 prints one line per row, cells apart by `|`, and nothing at all for a table without rows. */
|
||||
export function parseRows(table: string, output: string): Row[] {
|
||||
const columns = AUTOMX_TABLES[table].columns;
|
||||
const rows: Row[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") continue;
|
||||
const cells = line.replace(/\r$/, "").split("|");
|
||||
if (cells.length !== columns.length) throw new Error(`sqlite3 printed ${cells.length} cells for ${columns.length} columns`);
|
||||
rows.push(Object.fromEntries(columns.map((c, i) => [c, cellOf(cells[i])])));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The command that reads one table: read-only, one fixed statement. */
|
||||
export function readCommand(table: string): string[] {
|
||||
return ["sqlite3", "-readonly", AUTOMX_DATABASE, selectOf(table)];
|
||||
}
|
||||
|
||||
/** Read every table; one that fails is said in `unreadable` and does not hide the others. */
|
||||
export async function readAutomx(exec: Exec, container: string): Promise<AutomxRows> {
|
||||
const answer: AutomxRows = { database: AUTOMX_DATABASE, tables: {}, empty_names: [], unreadable: {} };
|
||||
for (const table of Object.keys(AUTOMX_TABLES)) {
|
||||
try {
|
||||
answer.tables[table] = parseRows(table, await exec(container, readCommand(table)));
|
||||
} catch (e) {
|
||||
const err = e as { stderr?: string; message?: string };
|
||||
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
|
||||
answer.unreadable[table] = text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
|
||||
}
|
||||
}
|
||||
for (const s of answer.tables.server ?? []) {
|
||||
if (String(s.name ?? "").trim() === "") answer.empty_names.push({ id: Number(s.id), type: String(s.type) });
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 612
|
||||
Content-Type: application/xml
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
GET https://autoconfig.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
|
||||
User-Agent: Thunderbird
|
||||
Accept: */*
|
||||
+1
@@ -0,0 +1 @@
|
||||
200 OK
|
||||
@@ -0,0 +1 @@
|
||||
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 612
|
||||
Content-Type: application/xml
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
GET https://automx.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
|
||||
User-Agent: Thunderbird
|
||||
+1
@@ -0,0 +1 @@
|
||||
200 OK
|
||||
+1
@@ -0,0 +1 @@
|
||||
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 612
|
||||
Content-Type: application/xml
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
GET https://autodiscover.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
|
||||
User-Agent: Thunderbird
|
||||
+1
@@ -0,0 +1 @@
|
||||
200 OK
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
GET https://autoconfig.novox.be/mail/config-v1.1.xml?emailaddress=nobody@unknown.example
|
||||
User-Agent: Thunderbird
|
||||
+1
@@ -0,0 +1 @@
|
||||
204 No Content
|
||||
+1
@@ -0,0 +1 @@
|
||||
Missing request argument "emailaddress"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 39
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
GET https://autoconfig.novox.be/mail/config-v1.1.xml
|
||||
User-Agent: Thunderbird
|
||||
+1
@@ -0,0 +1 @@
|
||||
400 Bad Request
|
||||
@@ -0,0 +1 @@
|
||||
<html><body>Show Thunderbird-style <a href="/mail/config-v1.1.xml?emailaddress=abc@example.com">XML configuration</a> for abc@example.com.</body></html>
|
||||
@@ -0,0 +1,3 @@
|
||||
Content-Length: 152
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
@@ -0,0 +1,2 @@
|
||||
GET https://autoconfig.novox.be/
|
||||
User-Agent: Mozilla/5.0
|
||||
@@ -0,0 +1 @@
|
||||
200 OK
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<title>404 Not Found</title>
|
||||
<h1>Not Found</h1>
|
||||
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 207
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+1
@@ -0,0 +1 @@
|
||||
GET https://autoconfig.novox.be/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=jochen@novox.be
|
||||
+1
@@ -0,0 +1 @@
|
||||
404 Not Found
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<title>500 Internal Server Error</title>
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 265
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
|
||||
User-Agent: Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.14326; Pro)
|
||||
Content-Type: text/xml
|
||||
Accept: */*
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||
<Request>
|
||||
<EMailAddress>jochen@novox.be</EMailAddress>
|
||||
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
|
||||
</Request>
|
||||
</Autodiscover>
|
||||
+1
@@ -0,0 +1 @@
|
||||
500 Internal Server Error
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
|
||||
User-Agent: Microsoft Office/16.0
|
||||
Content-Type: text/xml
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>nobody@unknown.example</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>
|
||||
+1
@@ -0,0 +1 @@
|
||||
204 No Content
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<title>500 Internal Server Error</title>
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 265
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
|
||||
User-Agent: Microsoft Office/16.0
|
||||
Content-Type: text/xml
|
||||
|
||||
this is not xml
|
||||
+1
@@ -0,0 +1 @@
|
||||
500 Internal Server Error
|
||||
+1
@@ -0,0 +1 @@
|
||||
Missing request argument "EMailAddress"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Length: 39
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Server: Werkzeug/3.1.9 Python/3.9.5
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
|
||||
User-Agent: Microsoft Office/16.0
|
||||
Content-Type: text/xml
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user