From b1acb3d9def5ffc42727daf7752f828b60a104dd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:35:27 +0200 Subject: [PATCH 1/3] Let a seat receive blocks as data its holder renders, placed where the machine has the hardware (hq ADR 0255) A module adding a battery to the bar had to write i3status-rust's TOML, so a second bar could not take its place. node-bar now receives a bar-neutral block: the contributor says what it shows, the holder renders it with its own template, places every bar and place once, and a contribution may name a capability the machine must report. The block is offered: the power module runs on servers without a bar. --- internal/catalogue/backup_test.go | 2 +- internal/catalogue/data_test.go | 6 +- internal/catalogue/declaration.go | 2 +- internal/catalogue/environment_into.go | 4 +- internal/catalogue/graphical_session.go | 102 ++++++- internal/catalogue/manifest.go | 5 + internal/catalogue/resolve.go | 6 +- internal/catalogue/seat_contributions.go | 49 ++- internal/catalogue/seat_data.go | 370 +++++++++++++++++++++++ internal/catalogue/seat_data_test.go | 203 +++++++++++++ internal/catalogue/seat_dependencies.go | 5 +- internal/catalogue/seats.go | 7 + 12 files changed, 744 insertions(+), 17 deletions(-) create mode 100644 internal/catalogue/seat_data.go create mode 100644 internal/catalogue/seat_data_test.go diff --git a/internal/catalogue/backup_test.go b/internal/catalogue/backup_test.go index 7dc65f62..d1bc8998 100644 --- a/internal/catalogue/backup_test.go +++ b/internal/catalogue/backup_test.go @@ -32,7 +32,7 @@ func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { if err != nil { t.Fatal(err) } - placed, err := seatContributions([]Manifest{pg, mail}, BackupSeat, "backup", Rendering{}, nil) + placed, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/catalogue/data_test.go b/internal/catalogue/data_test.go index 6e55f210..bb1d81d8 100644 --- a/internal/catalogue/data_test.go +++ b/internal/catalogue/data_test.go @@ -174,7 +174,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { "resources":[{"id":"meta","type":"directory","mode":"0700"}]}`) facts := map[string]string{"account-home": "/home/op"} with := Rendering{Settings: SettingsBy{"media": {{From: "node", Values: map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}}}}}} - backup, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindBackup, with, facts) + backup, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if backup != want { t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want) } - data, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindData, with, facts) + data, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil) if err != nil { t.Fatal(err) } @@ -192,7 +192,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if data != want { t.Fatalf("data lines:\n%s\nwant:\n%s", data, want) } - if _, err := seatContributions([]Manifest{agent}, BackupSeat, BackupKindData, Rendering{}, nil); err == nil { + if _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil { t.Fatal("a home directory with no account on the machine reached the holder as a placeholder") } // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 7c25a7bc..c11dd755 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -970,7 +970,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on // the node, as the jails are, and **last of every placeholder pass**: shell code is a // shell's own syntax, full of `${…}` no pass above should ever be shown. - if err := contributionsInto(copied, m, r.Modules, thisMachine, with); err != nil { + if err := contributionsInto(copied, m, r.Modules, thisMachine, with, r.Capabilities); err != nil { return nil, err } copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index 2cb6c17a..8ed1377a 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -547,7 +547,7 @@ func shellCode(modules []Manifest, shell, slot string) string { // `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment // is filled before the shell's code is, and each is replaced in a single pass over what the holder // wrote, so a contributed piece is never scanned again. -func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering) error { +func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool) error { if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 { return fmt.Errorf("%s", problems[0]) } @@ -576,7 +576,7 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, found := ofContributed.FindStringSubmatch(placeholder) first, second, _ := strings.Cut(found[2], ":") if found[1] == "contribution" { - placed, err := seatContributions(modules, first, second, with, facts) + placed, err := seatContributions(modules, m, found[2], with, facts, caps) if err != nil && failed == nil { failed = err } diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index 1407bc4d..43663e87 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -1,5 +1,10 @@ package catalogue +import ( + "fmt" + "strings" +) + // The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's // role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for // a role rather than each inventing one — and a machine running two of one role is refused at @@ -19,7 +24,8 @@ const ( ) // graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs -// research 026/05 starts it with. Three have none yet: the bar, the compositor and the secret +// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead, +// ADR 0255), the compositor and the secret // service are roles a second holder competes for, and nothing has needed to ask them anything. func graphicalSessionSeats() []Seat { const decided = "novox/hq ADR 0208" @@ -93,7 +99,11 @@ func graphicalSessionSeats() []Seat { {Name: "copy", Description: "Put text on the operator's clipboard.", Input: schema(map[string]string{"text": "the text"}, []string{"text"})}, }}, - {Name: BarSeat, Scope: ScopeNode, Decision: decided}, + // It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which + // the holder renders in its own grammar. A module that knows something about the machine — the + // power module its battery — gives the block; the bar knows no machine's hardware. + {Name: BarSeat, Scope: ScopeNode, Decision: decided, + Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}}, {Name: CompositorSeat, Scope: ScopeNode, Decision: decided}, {Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided}, } @@ -107,3 +117,91 @@ func withEnum(s map[string]any, property string, values ...string) map[string]an p["enum"] = values return s } + +// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255). +const BarKindBlock = "block" + +// barShows is what a contributed block may show, and the options each takes — the bar-neutral +// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template +// renders it (the shape's examples) before it is accepted. +var barShows = map[string]map[string]barOption{ + // The machine's battery: its charge, and how long it lasts or takes to fill. + "battery": {"device": {kind: "text"}}, + // One line a command prints, run every interval seconds: what the bar cannot know itself. + "command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}}, +} + +type barOption struct { + kind string // text, or seconds (a whole number from 1 to 86400) + required bool +} + +// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order, +// what it shows, and that kind's options. +// +// - bar: top or bottom; +// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the +// clock) — the two places every holder keeps for contributed blocks; +// - order: 0–99 within the place, 50 when absent; +// - shows: battery or command; options: what that kind takes. +func barBlockShape() *Shape { + return &Shape{ + Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}}, + {Field: "place", Values: []string{"resources", "status"}}}, + Order: "order", + Check: barBlockProblems, + Examples: []map[string]any{ + {"bar": "bottom", "place": "status", "shows": "battery"}, + {"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}}, + {"bar": "bottom", "place": "status", "shows": "command", + "options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}}, + }, + } +} + +func barBlockProblems(data map[string]any) []string { + var problems []string + for field := range data { + if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) { + problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field)) + } + } + shows, _ := data["shows"].(string) + takes, known := barShows[shows] + if !known { + return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"], + strings.Join(sortedKeys(barShows), ", "))) + } + options := map[string]any{} + if raw, has := data["options"]; has { + o, ok := raw.(map[string]any) + if !ok { + return append(problems, "a block's options are an object") + } + options = o + } + for name, v := range options { + opt, ok := takes[name] + if !ok { + problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name, + orNothing(sortedKeys(takes)))) + continue + } + switch opt.kind { + case "text": + if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" { + problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name)) + } + case "seconds": + if _, whole := wholeIn(v, 1, 86400); !whole { + problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name)) + } + } + } + for _, name := range sortedKeys(takes) { + if _, has := options[name]; takes[name].required && !has { + problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name)) + } + } + return problems +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index ded912a7..2c64d59c 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -60,6 +60,10 @@ type Claim struct { // module's own `tools` must list every verb the seat promises, which is how a module named // like its seat — the catalogue, the records — says they are one and the same. Serves []string `json:"serves,omitempty"` + // Renders is how this holder writes each kind its seat receives as data, by kind: a Go + // text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR + // 0255). The data is the mesh's, the format the holder's, as a module's facts template is. + Renders map[string]string `json:"renders,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's @@ -2030,6 +2034,7 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.shellProblems()...) problems = append(problems, m.contributionPlaceholderProblems()...) problems = append(problems, m.seatContributionProblems()...) + problems = append(problems, m.placementProblems()...) problems = append(problems, m.dataProblems()...) problems = append(problems, m.upgradeProblems()...) diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 8d140fef..2cfd163c 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -134,6 +134,10 @@ type Resolution struct { // here without a store lookup. Account string AccountHome string + // Capabilities are the machine's, as its profile reported them, carried from the node so a + // contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255) + // is decided here without a store lookup. + Capabilities map[string]bool // Modules in the order they were resolved: assigned first, then what they pulled in. Modules []Manifest @@ -699,7 +703,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain, - Account: node.Account, AccountHome: node.AccountHome, + Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities, Because: because, Needs: needs, Unhostable: unhostable, Kept: kept} for _, n := range providersFirst(order, catalogue) { resolution.Modules = append(resolution.Modules, catalogue[n]) diff --git a/internal/catalogue/seat_contributions.go b/internal/catalogue/seat_contributions.go index 12365123..46dc156d 100644 --- a/internal/catalogue/seat_contributions.go +++ b/internal/catalogue/seat_contributions.go @@ -30,7 +30,14 @@ type SeatContribution struct { // Kind is which of the seat's receivable kinds it is. Kind string `json:"kind"` // Content is the text, in the tool's own grammar. Never interpreted. - Content string `json:"content"` + Content string `json:"content,omitempty"` + // Data is the piece in the seat's own shape, for a kind the seat receives as data (novox/hq ADR + // 0255): the holder renders it with its template, and Content is then empty. + Data map[string]any `json:"data,omitempty"` + // IfCapability names a capability the machine must report for this contribution to be placed + // there (novox/hq ADR 0255): a battery's block only where the node-engine found a battery. The + // composition decides, from the machine's facts, not the tool at run time. + IfCapability string `json:"if-capability,omitempty"` } // ofContribution is where a holder places a kind: ${contribution::}. Loose inside the @@ -92,7 +99,17 @@ func (m Manifest) seatContributionProblems() []string { "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", m.Module, i+1, s.Name, c.Kind, kindsOf(s))) } - if strings.TrimSpace(c.Content) == "" { + if c.IfCapability != "" && !capabilityName.MatchString(c.IfCapability) { + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ + "capability's name", m.Module, i+1, c.IfCapability)) + } + switch { + case ok && r.Shape != nil: + problems = append(problems, shapedProblems(m.Module, i, r, c)...) + case len(c.Data) > 0: + problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+ + "given as `content`", m.Module, i+1, s.Name, c.Kind)) + case strings.TrimSpace(c.Content) == "": problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) } } @@ -120,8 +137,15 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string { continue } for _, f := range found { - seat, kind, two := strings.Cut(f[1], ":") - s, _, ok := receivable(seat, kind) + seat, rest, two := strings.Cut(f[1], ":") + // A kind received as data may be narrowed by its shape's placing fields (novox/hq ADR 0255). + kind, where, _ := strings.Cut(rest, ":") + s, rcv, ok := receivable(seat, kind) + if two && ok { + if _, err := placeholderWhere(rcv, where); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + } + } if !two || !ok { detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat) if s.Name != "" { @@ -152,17 +176,30 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string { // A directory placed in a person's home is named `${machine:account-home}/…` (novox/hq to-be 29); in a // kind that takes directories that is filled from the machine's facts as well, so the holder reads a // path, never a placeholder (novox/hq ADR 0233: an item in the operator's home is backed up too). -func seatContributions(modules []Manifest, seat, kind string, with Rendering, facts map[string]string) (string, error) { +// +// A kind received as data is rendered through the holder's template instead, narrowed by where +// (novox/hq ADR 0255); and a contribution naming a capability this machine did not report is left +// out, of either form. +func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, error) { + seat, rest, _ := strings.Cut(placeholder, ":") + kind, whereText, _ := strings.Cut(rest, ":") s, r, ok := receivable(seat, kind) if !ok { return "", nil } + if r.Shape != nil { + where, err := placeholderWhere(r, whereText) + if err != nil { + return "", err + } + return shapedContributions(modules, holder, s, r, where, caps) + } var failed error var b strings.Builder for _, m := range inModuleOrder(modules) { named := false for _, c := range m.allContributions() { - if c.Kind != kind { + if c.Kind != kind || !capable(c, caps) { continue } if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { diff --git a/internal/catalogue/seat_data.go b/internal/catalogue/seat_data.go new file mode 100644 index 00000000..b0405eaa --- /dev/null +++ b/internal/catalogue/seat_data.go @@ -0,0 +1,370 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "sort" + "strings" + "text/template" +) + +// A kind a seat receives as data, rendered by its holder (novox/hq ADR 0255). +// +// ADR 0212 made a contribution text in the tool's own grammar, which the controller places and never +// reads. That is right where the tool is the seat's for good — a hotkey daemon's trigger lines — and +// wrong where the seat's whole point is that its tool can be replaced: a module adding a battery to the +// bar would write i3status-rust's TOML, and a second bar would have to read it. So a seat may define a +// kind as data instead. The seat says the data's shape; a contributor gives data in that shape; the +// holder gives a template that renders one piece of it into its tool's grammar, as a module's facts +// template renders the roster (`facts`): the data is the mesh's, the format is the holder's. +// +// What the holder places is narrowed by the shape's placing fields — the bar's `bar` and `place` — in +// the placeholder, `${contribution:::=,…}`, and every combination of them is +// placed by the holder exactly once, so nothing a module contributes is dropped by a holder that forgot +// a corner of the shape. + +// Shape is what a kind received as data looks like. +type Shape struct { + // Places are the fields a holder narrows its placeholders by, each with every value it takes. + Places []PlaceField + // Order is the whole-number field that orders the pieces within one placeholder (lowest first, + // then module order); absent in a piece means OrderDefault. + Order string + // Check is what else is wrong with one piece's data, beyond its placing fields and its order. + Check func(data map[string]any) []string `json:"-"` + // Examples are pieces every holder's template must render, one for each variant the shape has, + // so a holder that cannot render one is refused at registration and not found on a machine. + Examples []map[string]any +} + +// PlaceField is one field a holder places by, and the values it takes. +type PlaceField struct { + Field string + Values []string +} + +// OrderDefault is a piece's order when it gives none: the middle of 0–99. +const OrderDefault = 50 + +// capabilityName is what an `if-capability` may name: a capability as a node's profile reports it. +var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON +// string — a valid basic string in TOML and in most tools' grammars — so a contributed command +// with quotes in it cannot break out of the holder's line. +var renderFuncs = template.FuncMap{ + "quote": func(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return string(b), err + }, +} + +// shapedProblems is what is wrong with one contribution of a kind received as data. +func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string { + var problems []string + at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind) + if strings.TrimSpace(c.Content) != "" { + problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)") + } + if len(c.Data) == 0 { + return append(problems, at+" has no data") + } + for _, p := range r.Shape.Places { + v, _ := c.Data[p.Field].(string) + if !oneOf(p.Values, v) { + problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s", + at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", "))) + } + } + if r.Shape.Order != "" { + if v, has := c.Data[r.Shape.Order]; has { + if _, ok := wholeIn(v, 0, 99); !ok { + problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99", + at, r.Shape.Order, v)) + } + } + } + if r.Shape.Check != nil { + for _, p := range r.Shape.Check(c.Data) { + problems = append(problems, at+": "+p) + } + } + return problems +} + +// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float. +func wholeIn(v any, lo, hi int) (int, bool) { + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f, ok = float64(i), true + } + } + if !ok || f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, false + } + return int(f), true +} + +// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the +// kind's shape. Empty narrows nothing. +func placeholderWhere(r Receivable, where string) (map[string]string, error) { + out := map[string]string{} + if where == "" { + return out, nil + } + if r.Shape == nil { + return nil, fmt.Errorf("%s is text, which a placeholder does not narrow", r.Kind) + } + for _, pair := range strings.Split(where, ",") { + field, value, ok := strings.Cut(pair, "=") + var p *PlaceField + for i := range r.Shape.Places { + if r.Shape.Places[i].Field == field { + p = &r.Shape.Places[i] + } + } + switch { + case !ok || p == nil: + return nil, fmt.Errorf("%q narrows by %s, and %s is placed by %s", where, field, r.Kind, placeFields(r)) + case !oneOf(p.Values, value): + return nil, fmt.Errorf("%q says %s=%s; %s is one of %s", where, field, value, field, strings.Join(p.Values, ", ")) + case out[field] != "": + return nil, fmt.Errorf("%q names %s twice", where, field) + } + out[field] = value + } + return out, nil +} + +func placeFields(r Receivable) string { + var names []string + for _, p := range r.Shape.Places { + names = append(names, p.Field) + } + return strings.Join(names, ", ") +} + +// holderTemplate is the holder's template for a kind, parsed. +func holderTemplate(holder Manifest, seat Seat, kind string) (*template.Template, error) { + for _, c := range holder.Claims { + if s, known := SeatNamed(c.Name); !known || s.Name != seat.Name { + continue + } + text, has := c.Renders[kind] + if !has || strings.TrimSpace(text) == "" { + break + } + t, err := template.New(seat.Name + ":" + kind).Funcs(renderFuncs).Parse(text) + if err != nil { + return nil, fmt.Errorf("%s's template for %s:%s does not parse: %v", holder.Module, seat.Name, kind, err) + } + return t, nil + } + return nil, fmt.Errorf("%s places %s:%s, which is data, and its claim gives no template for it in `renders` "+ + "(novox/hq ADR 0255)", holder.Module, seat.Name, kind) +} + +// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields and +// `module`, the contributor. A value the piece does not have reads as nothing in a `with` or an `if`; +// printed bare it would write `` into the tool's file, which is refused here instead. +func renderPiece(t *template.Template, module string, data map[string]any) (string, error) { + view := map[string]any{"module": module} + for k, v := range data { + view[k] = v + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", fmt.Errorf("rendering %s's piece: %v", module, err) + } + out := b.String() + if strings.Contains(out, "") { + return "", fmt.Errorf("rendering %s's piece printed a value it does not have: %q", module, out) + } + return out, nil +} + +// placedPiece is one contributed piece on its way into a holder's file. +type placedPiece struct { + module string + data map[string]any + order int +} + +// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the +// machine's capabilities, ordered, and rendered through the holder's template — each piece under a +// comment line naming its module. +func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, error) { + var pieces []placedPiece + for _, m := range inModuleOrder(modules) { + for _, c := range m.allContributions() { + if c.Kind != r.Kind || !capable(c, caps) { + continue + } + if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { + continue + } + match := true + for field, value := range where { + if v, _ := c.Data[field].(string); v != value { + match = false + } + } + if !match { + continue + } + order := OrderDefault + if v, ok := wholeIn(c.Data[r.Shape.Order], 0, 99); ok { + order = v + } + pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order}) + } + } + if len(pieces) == 0 { + return "", nil + } + t, err := holderTemplate(holder, s, r.Kind) + if err != nil { + return "", err + } + sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order }) + var b strings.Builder + for _, p := range pieces { + out, err := renderPiece(t, p.module, p.data) + if err != nil { + return "", err + } + fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module) + b.WriteString(out) + if !strings.HasSuffix(out, "\n") { + b.WriteString("\n") + } + } + return b.String(), nil +} + +// capable is whether a contribution applies on a machine with these capabilities: always, unless it +// names one with `if-capability` the machine did not report (novox/hq ADR 0255). A machine whose +// capabilities are not known has none, so a conditional piece is left out rather than guessed in. +func capable(c SeatContribution, caps map[string]bool) bool { + return c.IfCapability == "" || caps[c.IfCapability] +} + +// placementProblems is what is wrong with how a holder places the kinds its seats receive as data: +// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing +// fields placed exactly once across its files — or something contributed would be dropped, or written +// twice (novox/hq ADR 0255). +func (m Manifest) placementProblems() []string { + var problems []string + for _, c := range m.Claims { + s, known := SeatNamed(c.Name) + if !known { + continue + } + for _, r := range s.Receives { + // A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it + // would refuse every bar registered before the kind existed, and the controller that + // defines the kind ships first. Its contributions wait, unplaced, until it places them. + if r.Shape == nil || !placesKind(m, s, r.Kind) { + continue + } + t, err := holderTemplate(m, s, r.Kind) + if err != nil { + problems = append(problems, err.Error()) + continue + } + for _, ex := range r.Shape.Examples { + if out, err := renderPiece(t, "example", ex); err != nil || strings.TrimSpace(out) == "" { + problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v", + m.Module, s.Name, r.Kind, ex, err)) + } + } + placed := map[string]int{} + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + kind, whereText, _ := strings.Cut(rest, ":") + if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind { + continue + } + where, err := placeholderWhere(r, whereText) + if err != nil { + continue // said by seatPlaceholderProblems + } + for _, combo := range combinations(r.Shape.Places) { + if covers(where, combo) { + placed[comboKey(combo)]++ + } + } + } + } + for _, combo := range combinations(r.Shape.Places) { + switch n := placed[comboKey(combo)]; { + case n == 0: + problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+ + "placed once, or a contribution to it is lost (novox/hq ADR 0255)", + m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r))) + case n > 1: + problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+ + "is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n)) + } + } + } + } + return problems +} + +// placesKind is whether any of a holder's files names the kind's placeholder. +func placesKind(m Manifest, s Seat, kind string) bool { + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + k, _, _ := strings.Cut(rest, ":") + if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind { + return true + } + } + } + return false +} + +// combinations is every assignment of a value to each placing field. +func combinations(places []PlaceField) []map[string]string { + out := []map[string]string{{}} + for _, p := range places { + var next []map[string]string + for _, partial := range out { + for _, v := range p.Values { + c := map[string]string{p.Field: v} + for k, w := range partial { + c[k] = w + } + next = append(next, c) + } + } + out = next + } + return out +} + +func covers(where, combo map[string]string) bool { + for field, value := range where { + if combo[field] != value { + return false + } + } + return true +} + +func comboKey(combo map[string]string) string { + var parts []string + for _, k := range sortedKeys(combo) { + parts = append(parts, k+"="+combo[k]) + } + return strings.Join(parts, ",") +} diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go new file mode 100644 index 00000000..f6a537ec --- /dev/null +++ b/internal/catalogue/seat_data_test.go @@ -0,0 +1,203 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder +// renders with its template; the holder places every combination of the shape's placing fields once; +// a contribution may be placed only where the machine reports a capability; and an offered kind makes +// no dependency on its seat. + +const barTemplate = `{{if eq .shows "battery"}}[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end}}{{else if eq .shows "command"}}[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{end}}` + +func barHolder() Manifest { + place := func(bar string) string { + return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" + + "own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n" + } + return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}}, + Resources: []map[string]any{ + {"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")}, + {"id": "top", "type": "file", "path": "/t", "content": place("top")}, + }} +} + +func block(data string) SeatContribution { + var d map[string]any + if err := json.Unmarshal([]byte(data), &d); err != nil { + panic(err) + } + return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d} +} + +func composedFile(t *testing.T, r Resolution, id string) string { + t.Helper() + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} + +func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`), + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}} + + // Ordered by order (20, 50, 70), then module order; each piece under its module's name; the + // command quoted so its own quotes cannot end the line. + want := "own-first\nown-middle\n" + + "# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" + + "# power\n[[block]]\nblock = \"battery\"\n" + + "# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" + + "own-last\n" + if got := composedFile(t, r, "a-bar.bottom"); got != want { + t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want) + } + want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n" + if got := composedFile(t, r, "a-bar.top"); got != want { + t.Fatalf("the top bar is\n%s\nnot\n%s", got, want) + } +} + +func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + func() SeatContribution { + c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) + c.IfCapability = "battery" + return c + }(), + {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_BATTERY 1 x", IfCapability: "battery"}, + }} + for _, c := range []struct { + caps map[string]bool + has bool + }{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} { + r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), hotkeysHolder(), power}} + bar, keys := composedFile(t, r, "a-bar.bottom"), composedFile(t, r, "triggerhappy.triggers") + if strings.Contains(bar, `block = "battery"`) != c.has || strings.Contains(keys, "KEY_BATTERY") != c.has { + t.Errorf("with %v: the battery placed is not %v:\n%s\n%s", c.caps, c.has, bar, keys) + } + } +} + +func TestABlockOutsideTheShapeIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", + `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", + `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", + `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", + `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + + `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a block in the shape was refused: %v", err) + } +} + +func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { + holder := func(template string, contents ...string) string { + var res []string + for i, c := range contents { + b, _ := json.Marshal(c) + res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) + } + tb, _ := json.Marshal(template) + renders := "" + if template != "" { + renders = `,"renders":{"block":` + string(tb) + `}` + } + return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` + } + all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + + "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" + cases := map[string]string{ + holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", + holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", + holder("", all): "gives no template", + holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", + holder(`{{.nothing}}`, all): "renders nothing", + holder(`{{if}`, all): "does not parse", + holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", + holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", + holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) + } + } + for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), + // A holder that places no block at all is older than the kind, and is not refused for it. + holder("", "no blocks here")} { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", raw, err) + } + } +} + +func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, + }} + for _, seat := range DependsOn(power) { + if seat == BarSeat { + t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power)) + } + } + // And on a machine with no bar it is placed nowhere, and nothing fails. + r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true}, + Modules: []Manifest{hotkeysHolder(), power}} + if _, err := r.Declaration(Rendering{}); err != nil { + t.Fatal(err) + } +} + +func TestTheBarReceivesBlocksAsData(t *testing.T) { + s, _ := SeatNamed(BarSeat) + if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered { + t.Fatalf("node-bar receives %+v", s.Receives) + } + for _, ex := range s.Receives[0].Shape.Examples { + if p := barBlockProblems(ex); len(p) > 0 { + t.Errorf("the shape's own example %v is refused: %v", ex, p) + } + } +} diff --git a/internal/catalogue/seat_dependencies.go b/internal/catalogue/seat_dependencies.go index dc2e0f0c..2acb6ec5 100644 --- a/internal/catalogue/seat_dependencies.go +++ b/internal/catalogue/seat_dependencies.go @@ -118,8 +118,11 @@ func contributedTo(m Manifest) []string { } // Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the // mesh does not define is refused at registration and depends on nothing here. + // A kind the seat only offers to place makes no dependency (novox/hq ADR 0255). for _, c := range m.Contributions { - if s, known := SeatNamed(c.Seat); known { + if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered { + continue + } else if s.Name != "" { out = append(out, s.Name) } } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 82c519cb..01643dd5 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -68,6 +68,13 @@ type Receivable struct { // only the mesh knows where. Off for every kind written in a tool's grammar that has its own // `${…}` — a shell's — where the mesh filling one would change what the tool reads. Dirs bool + // Shape, when set, makes the kind data rather than text (novox/hq ADR 0255): a contributor gives + // `data` in this shape, and the holder renders each piece with the template its claim gives. + Shape *Shape + // Offered says a contribution of this kind does not depend on the seat (novox/hq ADR 0255): where + // nobody holds it nothing is placed, and the contributor runs there all the same. A battery's bar + // block is offered — the power module is on every machine, and a server has no bar. + Offered bool } // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the From 976633836880f74b064ed45319c76162c8f47547 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:56:09 +0200 Subject: [PATCH 2/3] Build every artifact the forge declares in the forge tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue's forge gained an npm-registry bundle (hq ADR 0251 §4), so resolving it against its code bundle alone failed three tests on main and on every pull request. --- .../catalogue/foundation_manifests_test.go | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/internal/catalogue/foundation_manifests_test.go b/internal/catalogue/foundation_manifests_test.go index a78fe472..2c94f9e6 100644 --- a/internal/catalogue/foundation_manifests_test.go +++ b/internal/catalogue/foundation_manifests_test.go @@ -177,19 +177,8 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { // wrong on every node whose assignment differs, and wrong for a second reason on a node given the // port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves // in an `env` at all is a declaration, not a manifest. -// forgeBuilt is every artifact the forge's manifest builds: its code, and since hq ADR 0251 the npm -// registry it serves beside it. A manifest resolves only against all of what it asked to be built. -func forgeBuilt() []Built { - var out []Built - for _, name := range []string{"code", "npm-registry"} { - out = append(out, Built{Name: name, Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "gitea/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest}) - } - return out -} - func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { - forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt()) + forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea"))) if err != nil { t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) } @@ -242,7 +231,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { t.Helper() forge := catalogueManifest(t, "gitea") - resolved, err := forge.Resolve(forgeBuilt()) + resolved, err := forge.Resolve(forgeBuilt(forge)) if err != nil { t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) } @@ -314,3 +303,15 @@ func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) { t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"]) } } + +// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the +// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third +// artifact does not break every forge test again. +func forgeBuilt(forge Manifest) []Built { + var out []Built + for _, a := range forge.Build.Artifacts { + out = append(out, Built{Name: a.Name, Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest}) + } + return out +} From 8984c3437f6316bb1a7b31f0d95642914299fccc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 14:33:05 +0200 Subject: [PATCH 3/3] Leave out a block its holder cannot render, and keep if-capability to known names on offered kinds (hq ADR 0255) A piece whose shows the holder's template does not know rendered as nothing and failed the whole machine's declaration; it is now left out and named, for push, plan and the merge gate. quote escapes DEL, which TOML refuses bare. An if-capability nothing detects, or on a kind a holder depends on, would drop a piece silently, so both are refused. --- cmd/mesh-controller/merge_gate.go | 8 ++ cmd/mesh-controller/plan.go | 7 +- cmd/mesh-controller/sendable.go | 3 + internal/catalogue/backup_test.go | 2 +- internal/catalogue/data_test.go | 6 +- internal/catalogue/declaration.go | 15 +- internal/catalogue/environment_into.go | 7 +- internal/catalogue/seat_contributions.go | 30 +++- internal/catalogue/seat_data.go | 58 ++++++-- internal/catalogue/seat_data_test.go | 175 +++++++++++++++++++++-- 10 files changed, 267 insertions(+), 44 deletions(-) diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index 93ca7c65..269f76cf 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -135,6 +135,7 @@ type mergeComposed struct { Problems []string `json:"problems,omitempty"` Withheld []string `json:"withheld,omitempty"` Unbound []string `json:"unbound,omitempty"` + Unplaced []string `json:"unplaced,omitempty"` LeftOut map[string]string `json:"left-out,omitempty"` Resources []string `json:"resources,omitempty"` } @@ -372,6 +373,12 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error) v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", gm.Described, u)) } + // A contribution its holder's template renders nothing for (novox/hq ADR 0255): the machine + // would be sent without it, so a change that adds one is refused, naming it. + for _, u := range newOnly(gm.Change.Unplaced, gm.Base.Unplaced) { + v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a contribution unplaced — %s", + gm.Described, u)) + } for module, why := range gm.Change.LeftOut { if _, was := gm.Base.LeftOut[module]; !was { v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", @@ -780,6 +787,7 @@ func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[stri for _, u := range declared.unbound { c.Unbound = append(c.Unbound, u.String()) } + c.Unplaced = declared.unplaced sort.Strings(c.Withheld) sort.Strings(c.Unbound) out[m.Name] = c diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 648b682b..5b19c60c 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -419,7 +419,7 @@ func declarationWith(ctx context.Context, open *stores, node string, out := sendable{Resources: composed.Resources, Adoption: adoption, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld, - unbound: with.Unbound, foreseen: composed.Foreseen} + unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced} // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // 0221). Read only on the send path: a question about what would be sent records nothing. if choosing == Allocating { @@ -528,6 +528,11 @@ func reportLeftOut(node string, declared sendable) { for _, u := range declared.unbound { fmt.Printf("%s: %s\n", node, u) } + // And every contribution its holder could not render, which the machine is sent without (novox/hq + // ADR 0255). + for _, u := range declared.unplaced { + fmt.Printf("%s: %s\n", node, u) + } } // busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued. diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index eaadeb0b..2311522a 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -58,6 +58,9 @@ type sendable struct { // make (Foreseeing, novox/hq issue 275). Never on the wire, and a declaration that has any is never // sent. foreseen []string + // unplaced is every contribution its holder's template could not render, naming its module and + // why (novox/hq ADR 0255): left out of this declaration, for push and plan to say, never on the wire. + unplaced []string // Builds is the build of each module this declaration carries — module to the commit its build // was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221). // Composed only on the send path; nil records that it is not known. diff --git a/internal/catalogue/backup_test.go b/internal/catalogue/backup_test.go index d1bc8998..4450ac27 100644 --- a/internal/catalogue/backup_test.go +++ b/internal/catalogue/backup_test.go @@ -32,7 +32,7 @@ func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { if err != nil { t.Fatal(err) } - placed, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) + placed, _, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/catalogue/data_test.go b/internal/catalogue/data_test.go index bb1d81d8..fde16ca2 100644 --- a/internal/catalogue/data_test.go +++ b/internal/catalogue/data_test.go @@ -174,7 +174,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { "resources":[{"id":"meta","type":"directory","mode":"0700"}]}`) facts := map[string]string{"account-home": "/home/op"} with := Rendering{Settings: SettingsBy{"media": {{From: "node", Values: map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}}}}}} - backup, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil) + backup, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if backup != want { t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want) } - data, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil) + data, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil) if err != nil { t.Fatal(err) } @@ -192,7 +192,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if data != want { t.Fatalf("data lines:\n%s\nwant:\n%s", data, want) } - if _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil { + if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil { t.Fatal("a home directory with no account on the machine reached the holder as a placeholder") } // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index c11dd755..27f42b4f 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -291,6 +291,10 @@ type Composed struct { // as `module/name`, sorted: what the next send will make. Never set on a declaration that is // sent — a placeholder in a sealed file is a credential the process cannot read. Foreseen []string + // Unplaced is every contribution the holder's template could not render into something, each + // naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told + // everything else. Push and plan say it; the merge gate refuses a change that adds one. + Unplaced []string } // ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of @@ -326,8 +330,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { owner := map[string]string{} received := map[string]map[string][]Contribution{} leftOut := map[string]string{} - var foreseen []string - resources, err := r.compose(with, owner, received, leftOut, &foreseen) + var foreseen, unplaced []string + resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced) if err != nil { return Composed{}, err } @@ -340,8 +344,9 @@ func (r Resolution) Compose(with Rendering) (Composed, error) { "sealed": with.BusMembership, "mode": "0600", }) } + sort.Strings(unplaced) return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, - Foreseen: foreseen}, nil + Foreseen: foreseen, Unplaced: unplaced}, nil } // BusMembershipID names the resource carrying a machine's membership for the new bus, and @@ -352,7 +357,7 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json" func (r Resolution) compose(with Rendering, owner map[string]string, received map[string]map[string][]Contribution, leftOut map[string]string, - foreseen *[]string) ([]map[string]any, error) { + foreseen, unplaced *[]string) ([]map[string]any, error) { // **A setting is judged where it is stored, and an impossible one costs a module, not a // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes // this module uncomposable; it is left out of the declaration — its held things kept, its @@ -970,7 +975,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on // the node, as the jails are, and **last of every placeholder pass**: shell code is a // shell's own syntax, full of `${…}` no pass above should ever be shown. - if err := contributionsInto(copied, m, r.Modules, thisMachine, with, r.Capabilities); err != nil { + if err := contributionsInto(copied, m, r.Modules, thisMachine, with, r.Capabilities, unplaced); err != nil { return nil, err } copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index 8ed1377a..41128e4a 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -547,7 +547,7 @@ func shellCode(modules []Manifest, shell, slot string) string { // `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment // is filled before the shell's code is, and each is replaced in a single pass over what the holder // wrote, so a contributed piece is never scanned again. -func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool) error { +func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool, unplaced *[]string) error { if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 { return fmt.Errorf("%s", problems[0]) } @@ -576,10 +576,13 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, found := ofContributed.FindStringSubmatch(placeholder) first, second, _ := strings.Cut(found[2], ":") if found[1] == "contribution" { - placed, err := seatContributions(modules, m, found[2], with, facts, caps) + placed, left, err := seatContributions(modules, m, found[2], with, facts, caps) if err != nil && failed == nil { failed = err } + if unplaced != nil { + *unplaced = append(*unplaced, left...) + } return placed } return shellCode(modules, first, second) diff --git a/internal/catalogue/seat_contributions.go b/internal/catalogue/seat_contributions.go index 46dc156d..9a408bc5 100644 --- a/internal/catalogue/seat_contributions.go +++ b/internal/catalogue/seat_contributions.go @@ -99,9 +99,22 @@ func (m Manifest) seatContributionProblems() []string { "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", m.Module, i+1, s.Name, c.Kind, kindsOf(s))) } - if c.IfCapability != "" && !capabilityName.MatchString(c.IfCapability) { - problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ - "capability's name", m.Module, i+1, c.IfCapability)) + // Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a + // kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the + // capability would lose the piece without a word. + if c.IfCapability != "" { + switch { + case !capabilityName.MatchString(c.IfCapability): + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ + "capability's name", m.Module, i+1, c.IfCapability)) + case !oneOf(KnownCapabilities, c.IfCapability): + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+ + "reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", "))) + case ok && !r.Offered: + problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+ + "kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)", + m.Module, i+1, s.Name, c.Kind, c.IfCapability)) + } } switch { case ok && r.Shape != nil: @@ -180,17 +193,20 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string { // A kind received as data is rendered through the holder's template instead, narrowed by where // (novox/hq ADR 0255); and a contribution naming a capability this machine did not report is left // out, of either form. -func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, error) { +// +// What it could not render is answered as unplaced, each naming its module and why, and left out: one +// piece the holder's template does not know is that piece's fault, not the machine's. +func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, []string, error) { seat, rest, _ := strings.Cut(placeholder, ":") kind, whereText, _ := strings.Cut(rest, ":") s, r, ok := receivable(seat, kind) if !ok { - return "", nil + return "", nil, nil } if r.Shape != nil { where, err := placeholderWhere(r, whereText) if err != nil { - return "", err + return "", nil, err } return shapedContributions(modules, holder, s, r, where, caps) } @@ -244,5 +260,5 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string, } } } - return b.String(), failed + return b.String(), nil, failed } diff --git a/internal/catalogue/seat_data.go b/internal/catalogue/seat_data.go index b0405eaa..4b09b751 100644 --- a/internal/catalogue/seat_data.go +++ b/internal/catalogue/seat_data.go @@ -49,17 +49,27 @@ type PlaceField struct { // OrderDefault is a piece's order when it gives none: the middle of 0–99. const OrderDefault = 50 -// capabilityName is what an `if-capability` may name: a capability as a node's profile reports it. +// capabilityName is the shape of a capability's name, as a node's profile reports it. var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) +// KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the +// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its +// contribution out on every machine, silently, so it is refused. A detector added there is added here. +var KnownCapabilities = []string{ + "battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager", + "power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager", + "uplink-systemd-networkd", "virtualisation", +} + // renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON -// string — a valid basic string in TOML and in most tools' grammars — so a contributed command -// with quotes in it cannot break out of the holder's line. -var renderFuncs = template.FuncMap{ - "quote": func(v any) (string, error) { - b, err := json.Marshal(fmt.Sprint(v)) - return string(b), err - }, +// string with DEL escaped as well — JSON leaves it bare, and TOML refuses it — which makes it a valid +// basic string in TOML and in most tools' grammars, so a contributed command with quotes or control +// characters in it cannot break out of the holder's line. +var renderFuncs = template.FuncMap{"quote": quote} + +func quote(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err } // shapedProblems is what is wrong with one contribution of a kind received as data. @@ -197,7 +207,11 @@ type placedPiece struct { // shapedContributions is every module's data of one kind to one seat, narrowed by where and by the // machine's capabilities, ordered, and rendered through the holder's template — each piece under a // comment line naming its module. -func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, error) { +// +// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is +// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's +// declaration go on (novox/hq ADR 0255). +func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) { var pieces []placedPiece for _, m := range inModuleOrder(modules) { for _, c := range m.allContributions() { @@ -224,18 +238,24 @@ func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivab } } if len(pieces) == 0 { - return "", nil + return "", nil, nil } t, err := holderTemplate(holder, s, r.Kind) if err != nil { - return "", err + return "", nil, err } sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order }) var b strings.Builder + var unplaced []string for _, p := range pieces { out, err := renderPiece(t, p.module, p.data) + if err == nil && strings.TrimSpace(out) == "" { + err = fmt.Errorf("%s's template renders nothing for it", holder.Module) + } if err != nil { - return "", err + unplaced = append(unplaced, fmt.Sprintf("%s's %s:%s %s is not placed by %s: %v", + p.module, s.Name, r.Kind, describePiece(p.data), holder.Module, err)) + continue } fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module) b.WriteString(out) @@ -243,7 +263,19 @@ func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivab b.WriteString("\n") } } - return b.String(), nil + return b.String(), unplaced, nil +} + +// describePiece is a piece in one line, its fields in order, for a person reading why it was left out. +func describePiece(data map[string]any) string { + var parts []string + for _, k := range sortedKeys(data) { + if _, nested := data[k].(map[string]any); nested { + continue + } + parts = append(parts, fmt.Sprintf("%s=%v", k, data[k])) + } + return "(" + strings.Join(parts, " ") + ")" } // capable is whether a contribution applies on a machine with these capabilities: always, unless it diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go index f6a537ec..a67186e8 100644 --- a/internal/catalogue/seat_data_test.go +++ b/internal/catalogue/seat_data_test.go @@ -2,6 +2,8 @@ package catalogue import ( "encoding/json" + "fmt" + "strconv" "strings" "testing" ) @@ -83,26 +85,175 @@ func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T } func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) { - power := Manifest{Module: "power", Contributions: []SeatContribution{ - func() SeatContribution { - c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) - c.IfCapability = "battery" - return c - }(), - {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_BATTERY 1 x", IfCapability: "battery"}, - }} + c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) + c.IfCapability = "battery" + power := Manifest{Module: "power", Contributions: []SeatContribution{c}} for _, c := range []struct { caps map[string]bool has bool }{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} { - r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), hotkeysHolder(), power}} - bar, keys := composedFile(t, r, "a-bar.bottom"), composedFile(t, r, "triggerhappy.triggers") - if strings.Contains(bar, `block = "battery"`) != c.has || strings.Contains(keys, "KEY_BATTERY") != c.has { - t.Errorf("with %v: the battery placed is not %v:\n%s\n%s", c.caps, c.has, bar, keys) + r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has { + t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar) } } } +func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { + cases := map[string]string{ + // A name nothing detects would leave the piece out on every machine. + `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", + // A kind the holder depends on is never left out for want of something. + `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", + `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + for _, name := range []string{"battery", "power-meter", "seat"} { + if !oneOf(KnownCapabilities, name) { + t.Errorf("%s is not a known capability", name) + } + } +} + +func TestResolveCarriesTheMachinesCapabilities(t *testing.T) { + node := workstation() + node.Capabilities["battery"] = true + r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{}) + if err != nil { + t.Fatal(err) + } + if !r.Capabilities["battery"] || !r.Capabilities["seat"] { + t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities) + } +} + +func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) { + // A shows the shape gained after the holder's template was written: built here, past the check. + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"gpu"}`), + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + }} + r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}} + c, err := r.Compose(Rendering{}) + if err != nil { + t.Fatalf("one piece failed the whole machine: %v", err) + } + if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") || + !strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") { + t.Fatalf("unplaced: %v", c.Unplaced) + } + for _, res := range c.Resources { + if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) { + t.Fatalf("the piece beside it was lost too: %s", res["content"]) + } + } +} + +func TestTheCataloguesBarRendersEveryExampleOfTheShape(t *testing.T) { + holder := catalogueManifest(t, "i3status-rust") + s, _ := SeatNamed(BarSeat) + if !placesKind(holder, s, BarKindBlock) { + t.Skip("the catalogue beside this checkout has a bar that places no blocks yet") + } + tmpl, err := holderTemplate(holder, s, BarKindBlock) + if err != nil { + t.Fatal(err) + } + for _, ex := range s.Receives[0].Shape.Examples { + out, err := renderPiece(tmpl, "example", ex) + if err != nil || !strings.Contains(out, "[[block]]") { + t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) + } + } + for shows := range barShows { + covered := false + for _, ex := range s.Receives[0].Shape.Examples { + covered = covered || ex["shows"] == shows + } + if !covered { + t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) + } + } +} + +func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) { + for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} { + q, err := quote(in) + if err != nil { + t.Fatal(err) + } + got, err := tomlBasicString(q) + if err != nil || got != in { + t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err) + } + } +} + +// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it: +// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F +// and U+007F, which are written as escapes. +func tomlBasicString(s string) (string, error) { + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return "", fmt.Errorf("not quoted") + } + var b strings.Builder + rs := []rune(s[1 : len(s)-1]) + for i := 0; i < len(rs); i++ { + r := rs[i] + switch { + case r == '"': + return "", fmt.Errorf("a bare quote at %d", i) + case r == 0x7f || (r < 0x20 && r != '\t'): + return "", fmt.Errorf("a bare control character %U at %d", r, i) + case r != '\\': + b.WriteRune(r) + continue + } + i++ + if i >= len(rs) { + return "", fmt.Errorf("an escape at the end") + } + switch rs[i] { + case 'b': + b.WriteRune('\b') + case 't': + b.WriteRune('\t') + case 'n': + b.WriteRune('\n') + case 'f': + b.WriteRune('\f') + case 'r': + b.WriteRune('\r') + case '"': + b.WriteRune('"') + case '\\': + b.WriteRune('\\') + case 'u', 'U': + n := 4 + if rs[i] == 'U' { + n = 8 + } + if i+n > len(rs)-1 { + return "", fmt.Errorf("a short \\u escape") + } + v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32) + if err != nil { + return "", err + } + b.WriteRune(rune(v)) + i += n + default: + return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i]) + } + } + return b.String(), nil +} + func TestABlockOutsideTheShapeIsRefused(t *testing.T) { cases := map[string]string{ `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data",