diff --git a/go/catalogue/login_shell_execute_test.go b/go/catalogue/login_shell_execute_test.go index 0b0c7b16..146e0604 100644 --- a/go/catalogue/login_shell_execute_test.go +++ b/go/catalogue/login_shell_execute_test.go @@ -1,6 +1,12 @@ package catalogue -import "testing" +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) // The login shell's `execute` runs any command as the operator account, which can become root without a // person, so the operator withheld it on the control-node until a call to it needs a person's approval @@ -34,3 +40,39 @@ func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) { } t.Run("compiled", check) } + +// Design 47 G §677, A-266, A-270: a process that serves a marked verb holds the gate only in Go and with a state `approvals` +// declared with key-ttl; no holder names the verb approvals where a verb is marked, and a seat with none leaves it to its holder. +func TestAHolderOfAMarkedVerbDeclaresItsApprovalsStateWithKeyTTL(t *testing.T) { + seat, ok := SeatNamed(LoginShellSeat) + if !ok { + t.Fatal("node-login-shell is not defined") + } + holder := func(language string, state ...BusStateEntry) Manifest { + return Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode, Serves: []string{"execute"}}}, + Resources: []map[string]any{{"id": "p", "type": "process", "artifact": "program", "serves": []any{"seat:" + LoginShellSeat + ".execute"}}}, + Build: &Build{Artifacts: []Artifact{{Name: "program", Language: language}}}, BusState: state} + } + keyed := BusStateEntry{Name: "approvals", Storage: StorageKeyValue, Template: TemplateState, + Options: map[string]json.RawMessage{OptionLifetimePerKey: json.RawMessage("true")}} + plain := BusStateEntry{Name: "approvals", Storage: StorageKeyValue, Template: TemplateState} + for name, c := range map[string]struct { + m Manifest + refused string + }{ + "go with key-ttl": {holder("go", keyed), ""}, + "go without key-ttl": {holder("go", plain), "key-ttl"}, + "go with no state": {holder("go"), "key-ttl"}, + "typescript with key-ttl": {holder("typescript", keyed), "only Go"}, + } { + err := CanHold(c.m, seat) + if (c.refused == "") != (err == nil) || err != nil && !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: %v, want refusal containing %q", name, err, c.refused) + } + } + // A seat with no marked verb: a holder serving no verb named approvals is still refused for it. + open := Seat{Name: "s", Scope: ScopeNode, Serves: []Verb{{Name: "a"}, {Name: seatdef.ApprovalsVerb}}} + if got := unservedVerbs([]string{"a"}, open.Serves); len(got) != 1 || got[0] != "approvals" { + t.Errorf("a seat with no marked verb: approvals unserved is %v, want it named", got) + } +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 528580d1..e0dc9423 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "slices" "strings" "sync/atomic" ) @@ -201,6 +202,9 @@ func CanHold(m Manifest, seat Seat) error { "serves or among its own tools", m.Module, seat.Name, strings.Join(missing, ", ")) } + if err := approvalsStateRefusal(m, seat); err != nil { + return err + } // And nothing the seat does not promise: a verb named here that the protocol lacks is served // to nobody, which is a typo the holder would otherwise discover as a caller's timeout. if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 { @@ -211,6 +215,65 @@ func CanHold(m Manifest, seat Seat) error { return nil } +// approvalsStateRefusal is why a process of m that serves a marked verb of seat cannot hold the gate, or nothing (design 47 G +// §677, A-270): only Go holds the gate, and its template state `approvals` (a `requires` entry) is declared with `key-ttl`, +// since its bound keys `unanswered.` need it. A marked verb served by the tool runner's bundle needs neither. +func approvalsStateRefusal(m Manifest, seat Seat) error { + var marked []string + for _, v := range seat.Serves { + if v.NeedsApproval { + marked = append(marked, "seat:"+seat.Name+"."+v.Name) + } + } + if len(marked) == 0 { + return nil + } + for id, serves := range m.ProcessServes() { + var verbs []string + for _, s := range serves { + if slices.Contains(marked, s) { + verbs = append(verbs, s) + } + } + if len(verbs) == 0 { + continue + } + if language := processLanguage(m, id); language != "" && !compilesToABinary(language) { + return fmt.Errorf("%s serves %s from its process %s written in %s: only Go holds the gate of a marked verb "+ + "(design 47 G §677)", m.Module, strings.Join(verbs, ", "), id, language) + } + kept := false + for _, e := range m.BusState { + if e.Name == "approvals" && e.Template == TemplateState && string(e.Options[OptionLifetimePerKey]) == "true" { + kept = true + } + } + if !kept { + return fmt.Errorf("%s serves %s from its process %s but declares no state `approvals` with the option key-ttl "+ + "(design 47 G §677, A-270): a holder of a marked verb keeps its calls' bound keys there", m.Module, strings.Join(verbs, ", "), id) + } + } + return nil +} + +// processLanguage is the language of the artifact the process resource id runs, or empty where the manifest names none. +func processLanguage(m Manifest, id string) string { + if m.Build == nil { + return "" + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "process" || fmt.Sprint(r["id"]) != id { + continue + } + for _, a := range m.Build.Artifacts { + if a.Name == fmt.Sprint(r["artifact"]) { + return a.Language + } + } + } + return "" +} + func providesAt(m Manifest, provision, scope string) bool { for _, o := range m.Provides { if o.Name == provision && o.At() == scope { diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index fabe1eb1..20828e3d 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -140,11 +140,15 @@ func unservedVerbs(tools []string, promised []Verb) []string { for _, t := range tools { has[t] = true } + marked := false + for _, v := range promised { + marked = marked || v.NeedsApproval + } var missing []string for _, v := range promised { // The verb approvals is the gate's, served wherever a marked verb is (the seat server or the tool runner), so no - // holder names it (design 47 G lead answer A-266). - if !has[v.Name] && !v.Optional && v.Name != seatdef.ApprovalsVerb { + // holder names it there (design 47 G lead answer A-266); a seat with no marked verb leaves it to its holder. + if !has[v.Name] && !v.Optional && !(marked && v.Name == seatdef.ApprovalsVerb) { missing = append(missing, v.Name) } }