package approval import ( "bytes" "encoding/json" "fmt" "math" "sort" "strconv" "strings" "unicode/utf8" "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" "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: `.` or `.`. 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`, `module` and `tool` or `seat` and `verb`, `build`, and // `arg.` 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) (operatorchannelclient.Act, error) { act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint} 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] }