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",