Main reverted G's half merge (#189, reverted by8afc3ebin #191) and then took the reader release's S1-S5, which copied G's code. G's form holds where they differ (process_bus.go, seats.go, state.go and their tests); the release's kept names stay so main's callers keep building: SeatViewPrefix beside SeatReadPrefix, and QueueInvoke's empty strings on not-ok (with its test TestQueueInvoke). The tree differs from G's headf30dee54only by those and the release's two tests (objects cap option, seat states and stream kinds). Builds: design 47 G (the milestone's sdk part, as atf30dee54) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
297 lines
8.2 KiB
Go
297 lines
8.2 KiB
Go
package approval
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"git.novox.be/novox/mesh-sdk/go/operatorask"
|
||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||
)
|
||
|
||
// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves.
|
||
type Call struct {
|
||
// Address is the address as called: `<module>.<tool>` or `<seat>.<verb>`.
|
||
Address string
|
||
// Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb.
|
||
Module, Seat, Name string
|
||
// Fingerprint is the build the bundle serves, from its composed environment (§5).
|
||
Fingerprint string
|
||
// Caller is the caller the bus proved.
|
||
Caller string
|
||
// Args are the call's arguments, a JSON object.
|
||
Args json.RawMessage
|
||
// Description is the tool's or verb's own description, whose first line the ask explains it by.
|
||
Description string
|
||
// Own says it is one of the runner's own tools served under the module's name (its ledger repairs), not its bundle's.
|
||
Own bool
|
||
}
|
||
|
||
// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9).
|
||
const MostArguments = 16 * 1024
|
||
|
||
// arguments are a call's arguments by name; a body that is not an object is refused.
|
||
func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||
args := map[string]json.RawMessage{}
|
||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||
return args, nil
|
||
}
|
||
if err := json.Unmarshal(raw, &args); err != nil {
|
||
return nil, fmt.Errorf("its arguments are not a JSON object: %v", err)
|
||
}
|
||
return args, nil
|
||
}
|
||
|
||
// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are
|
||
// more than MostArguments.
|
||
func refusedUnasked(raw json.RawMessage) string {
|
||
if len(raw) > MostArguments {
|
||
return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments)
|
||
}
|
||
if r, ok := outward.CheckSecret(string(raw)); !ok {
|
||
return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ActOf is the act a marked call binds (§8): `address`, `node` (none given for a process holder, lead answer A-255), `module` and `tool` or `seat` and `verb`, `build`, and
|
||
// `arg.<name>` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form.
|
||
func ActOf(c Call, node string) (operatorask.Act, error) {
|
||
act := operatorask.Act{"address": c.Address, "build": c.Fingerprint}
|
||
if node != "" { // a process holder's act names none (lead answer A-255)
|
||
act["node"] = node
|
||
}
|
||
if c.Seat == "" {
|
||
act["module"], act["tool"] = c.Module, c.Name
|
||
} else {
|
||
act["seat"], act["verb"] = c.Seat, c.Name
|
||
}
|
||
args, err := arguments(c.Args)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for name, raw := range args {
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
act["arg."+name] = s
|
||
continue
|
||
}
|
||
canon, err := Canonical(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err)
|
||
}
|
||
act["arg."+name] = canon
|
||
}
|
||
return act, nil
|
||
}
|
||
|
||
// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16
|
||
// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names.
|
||
func Canonical(raw json.RawMessage) (string, error) {
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
dec.UseNumber()
|
||
var v any
|
||
if err := dec.Decode(&v); err != nil {
|
||
return "", err
|
||
}
|
||
var b strings.Builder
|
||
if err := canonicalValue(&b, v); err != nil {
|
||
return "", err
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
func canonicalValue(b *strings.Builder, v any) error {
|
||
switch x := v.(type) {
|
||
case nil:
|
||
b.WriteString("null")
|
||
case bool:
|
||
b.WriteString(strconv.FormatBool(x))
|
||
case json.Number:
|
||
f, err := strconv.ParseFloat(string(x), 64)
|
||
if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||
return fmt.Errorf("the number %s is not a double", x)
|
||
}
|
||
b.WriteString(es6Number(f))
|
||
case string:
|
||
canonicalString(b, x)
|
||
case []any:
|
||
b.WriteByte('[')
|
||
for i, e := range x {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
if err := canonicalValue(b, e); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
b.WriteByte(']')
|
||
case map[string]any:
|
||
names := make([]string, 0, len(x))
|
||
for k := range x {
|
||
names = append(names, k)
|
||
}
|
||
sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) })
|
||
b.WriteByte('{')
|
||
for i, k := range names {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
canonicalString(b, k)
|
||
b.WriteByte(':')
|
||
if err := canonicalValue(b, x[k]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
b.WriteByte('}')
|
||
default:
|
||
return fmt.Errorf("a value of type %T", v)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3).
|
||
func es6Number(f float64) string {
|
||
if f == 0 {
|
||
return "0"
|
||
}
|
||
abs := math.Abs(f)
|
||
if abs >= 1e-6 && abs < 1e21 {
|
||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||
}
|
||
s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07
|
||
mant, exp, _ := strings.Cut(s, "e")
|
||
sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0")
|
||
return mant + "e" + sign + digits
|
||
}
|
||
|
||
func canonicalString(b *strings.Builder, s string) {
|
||
b.WriteByte('"')
|
||
for _, r := range s {
|
||
switch {
|
||
case r == '"':
|
||
b.WriteString(`\"`)
|
||
case r == '\\':
|
||
b.WriteString(`\\`)
|
||
case r == '\b':
|
||
b.WriteString(`\b`)
|
||
case r == '\f':
|
||
b.WriteString(`\f`)
|
||
case r == '\n':
|
||
b.WriteString(`\n`)
|
||
case r == '\r':
|
||
b.WriteString(`\r`)
|
||
case r == '\t':
|
||
b.WriteString(`\t`)
|
||
case r < 0x20:
|
||
fmt.Fprintf(b, `\u%04x`, r)
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
b.WriteByte('"')
|
||
}
|
||
|
||
func utf16Less(a, b string) bool {
|
||
ua, ub := utf16Of(a), utf16Of(b)
|
||
for i := 0; i < len(ua) && i < len(ub); i++ {
|
||
if ua[i] != ub[i] {
|
||
return ua[i] < ub[i]
|
||
}
|
||
}
|
||
return len(ua) < len(ub)
|
||
}
|
||
|
||
func utf16Of(s string) []uint16 {
|
||
out := make([]uint16, 0, utf8.RuneCountInString(s))
|
||
for _, r := range s {
|
||
if r >= 0x10000 {
|
||
r -= 0x10000
|
||
out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF)))
|
||
} else {
|
||
out = append(out, uint16(r))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10).
|
||
func Fingerprint(digest string) string {
|
||
hex := strings.TrimPrefix(digest, "sha256:")
|
||
if len(hex) > 24 {
|
||
hex = hex[:24]
|
||
}
|
||
var groups []string
|
||
for len(hex) > 4 {
|
||
groups, hex = append(groups, hex[:4]), hex[4:]
|
||
}
|
||
return strings.Join(append(groups, hex), " ")
|
||
}
|
||
|
||
// The bounds of the arguments shown in an ask (§11).
|
||
const (
|
||
ShownBytes, ShownLines = 2000, 40
|
||
PartBytes, PartLines = 1200, 20
|
||
)
|
||
|
||
// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole:
|
||
// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at
|
||
// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL.
|
||
func shown(raw json.RawMessage) (string, bool) {
|
||
args, _ := arguments(raw)
|
||
names := make([]string, 0, len(args))
|
||
for n := range args {
|
||
names = append(names, n)
|
||
}
|
||
sort.Strings(names)
|
||
whole := true
|
||
var lines []string
|
||
for _, n := range names {
|
||
var value string
|
||
if json.Unmarshal(args[n], &value) != nil {
|
||
value = string(args[n])
|
||
}
|
||
if r, ok := outward.Check(value); !ok {
|
||
value, whole = shapeOf(r), false
|
||
}
|
||
lines = append(lines, n+": "+value)
|
||
}
|
||
text := strings.Join(lines, "\n")
|
||
if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines {
|
||
if len(all) > PartLines {
|
||
all = all[:PartLines]
|
||
}
|
||
text = strings.Join(all, "\n")
|
||
if len(text) > PartBytes {
|
||
text = cutBytes(text, PartBytes)
|
||
}
|
||
text += "\nNOT SHOWN IN FULL"
|
||
whole = false
|
||
}
|
||
return text, whole
|
||
}
|
||
|
||
// shapeOf is what an argument outward.Check refuses is shown as.
|
||
func shapeOf(r outward.Refusal) string {
|
||
switch r.Class {
|
||
case "path":
|
||
return "‹path›"
|
||
case "address":
|
||
return "‹address›"
|
||
}
|
||
return "‹withheld›"
|
||
}
|
||
|
||
// cutBytes is s at most n bytes, cut at a character's end.
|
||
func cutBytes(s string, n int) string {
|
||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||
n--
|
||
}
|
||
return s[:n]
|
||
}
|