diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go new file mode 100644 index 0000000..e7524a9 --- /dev/null +++ b/go/delivery/view_answers.go @@ -0,0 +1,184 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" +) + +// The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The +// gate's merge ask (`merge..`, MergeAskKey) needs proof of who answered (ADR 0421 §1), so it is +// answered on the phone alone until issue 953 closes. The assigner's asks need no such proof, and the view may +// answer them: the hold of a fingerprint whose health check failed (`hold..`: release it or +// keep it held) and the restore of a data module set back (`restore...`: restore and +// lose newer writes, or leave it stopped, ADR 0429 §6). The assigner writes them in its bucket asks; the view writes +// its answer `.view` in its bucket answers, and the teller, which alone fills an ask's answer, takes it from +// there as it takes a warrant from the phone. +const ( + // ViewModule is the module of the view part. + ViewModule = "mesh-delivery-view" + // ViewAnswersBucket is the view's bucket of answers, the only state the view writes. + ViewAnswersBucket = ViewModule + "_answers" + // ViewAnswersHistory is the revisions the bucket keeps per key. + ViewAnswersHistory = 5 + // AssignerAsksBucket is the assigner's bucket of asks that need no proof of who answered. + AssignerAsksBucket = AssignerModule + "_asks" + // AssignerAsksHistory is the revisions the bucket keeps per key. + AssignerAsksHistory = 5 + + // AskKindHold is the kind of the assigner's ask about a held fingerprint. + AskKindHold = "hold" + // AskKindRestore is the kind of the assigner's ask about a data module set back. + AskKindRestore = "restore" + + viewWord = "view" +) + +// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's +// Module and fingerprint FP, a restore's Node, Module and FP. +type AskKey struct { + Kind string + Group string + Hash string + Node string + Module string + FP string +} + +// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the +// merge's Approve does (ADR 0421 §1, issue 953), the assigner's hold and restore do not. An unknown kind is refused. +func AskNeedsProof(kind string) (bool, error) { + switch kind { + case AskKindMerge: + return true, nil + case AskKindHold, AskKindRestore: + return false, nil + } + return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s or %s", kind, AskKindMerge, AskKindHold, AskKindRestore) +} + +// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold..`. +func HoldAskKey(module, fingerprint string) (string, error) { + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindHold + "." + key, nil +} + +// RestoreAskKey is the key, in AssignerAsksBucket, of the ask about a data module set back on a node from a +// fingerprint: `restore...`. +func RestoreAskKey(node, module, fingerprint string) (string, error) { + if err := checkName("node", node); err != nil { + return "", err + } + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindRestore + "." + node + "." + key, nil +} + +// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey); it refuses an unknown kind +// and a key whose parts do not fit its kind, naming the key. +func ParseAnyAskKey(key string) (AskKey, error) { + kind, rest, _ := strings.Cut(key, ".") + refused := func(err error) (AskKey, error) { + return AskKey{}, fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + switch kind { + case AskKindMerge: + _, group, hash, err := ParseAskKey(key) + if err != nil { + return AskKey{}, err + } + return AskKey{Kind: kind, Group: group, Hash: hash}, nil + case AskKindHold: + module, fp, err := ParseBuilderBuildKey(rest) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Module: module, FP: fp}, nil + case AskKindRestore: + node, build, ok := strings.Cut(rest, ".") + if !ok { + return refused(fmt.Errorf("a restore ask is restore...")) + } + if err := checkName("node", node); err != nil { + return refused(err) + } + module, fp, err := ParseBuilderBuildKey(build) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + } + _, err := AskNeedsProof(kind) + return refused(err) +} + +// ViewAnswerKey is the key, in ViewAnswersBucket, of the view's answer to the ask at askKey: `.view`. An ask +// that needs proof of who answered is refused with its reason: it is answered on the phone (issue 953). +func ViewAnswerKey(askKey string) (string, error) { + ask, err := ParseAnyAskKey(askKey) + if err != nil { + return "", err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return "", fmt.Errorf("delivery: the ask %s needs proof of who answered: answer it on the phone (issue 953)", askKey) + } + return askKey + "." + viewWord, nil +} + +// ParseViewAnswerKey is the inverse of ViewAnswerKey: the ask's key. It refuses a key without `.view`, an ask key of +// a kind that needs proof, and a key of another bucket, naming it. +func ParseViewAnswerKey(key string) (askKey string, err error) { + askKey, ok := strings.CutSuffix(key, "."+viewWord) + if !ok { + return "", fmt.Errorf("delivery: %q is not a view answer key, .view", key) + } + if _, err := ViewAnswerKey(askKey); err != nil { + return "", fmt.Errorf("delivery: %q is not a view answer key: %w", key, err) + } + return askKey, nil +} + +// ViewAnswer is the view's answer to an ask: the option the operator chose and their words. The entry's own time +// says when. +type ViewAnswer struct { + V int `json:"v"` + Option string `json:"option"` + Words string `json:"words,omitempty"` +} + +func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Option) == "" { + return fmt.Errorf("delivery: a view answer names no option") + } + return nil +} + +// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no option. +func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no +// option. +func DecodeViewAnswer(data []byte) (ViewAnswer, error) { + var a ViewAnswer + if err := decode(data, &a); err != nil { + return ViewAnswer{}, err + } + if err := a.check(); err != nil { + return ViewAnswer{}, err + } + return a, nil +} + +// SameViewAnswer says whether two view answers are the same value. +func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go new file mode 100644 index 0000000..d3bca5d --- /dev/null +++ b/go/delivery/view_answers_test.go @@ -0,0 +1,91 @@ +package delivery + +import ( + "strings" + "testing" +) + +// 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an +// unknown kind is refused. +func TestAskNeedsProof(t *testing.T) { + for kind, want := range map[string]bool{AskKindMerge: true, AskKindHold: false, AskKindRestore: false} { + if got, err := AskNeedsProof(kind); err != nil || got != want { + t.Errorf("AskNeedsProof(%q) = %v, %v; want %v", kind, got, err, want) + } + } + if _, err := AskNeedsProof("approve"); err == nil { + t.Errorf("an unknown kind was read") + } +} + +// 1297/B1c: each kind of ask key round-trips through ParseAnyAskKey; a key that fits no kind is refused, naming it. +func TestParseAnyAskKey(t *testing.T) { + fp := fp2of('d') + merge, _ := MergeAskKey("feat/r4g", []Head{{Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("1", 40)}}) + hold, err := HoldAskKey("mesh-mail", fp) + if err != nil || hold != "hold.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("HoldAskKey = %q, %v", hold, err) + } + restore, err := RestoreAskKey("ace", "mesh-mail", fp) + if err != nil || restore != "restore.ace.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("RestoreAskKey = %q, %v", restore, err) + } + for key, want := range map[string]AskKey{ + hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp}, + restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp}, + } { + if got, err := ParseAnyAskKey(key); err != nil || got != want { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", key, got, err) + } + } + if got, err := ParseAnyAskKey(merge); err != nil || got.Kind != AskKindMerge || got.Group != "feat/r4g" || len(got.Hash) != 12 { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", merge, got, err) + } + for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", ""} { + if _, err := ParseAnyAskKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseAnyAskKey(%q) = %v, want a refusal naming it", bad, err) + } + } + if _, err := RestoreAskKey("a.b", "m", fp); err == nil { + t.Errorf("a restore ask on a dotted node was keyed") + } +} + +// 1297/B1c: the view's answer key is the ask's key and `.view`, and round-trips; the merge's ask is refused with its +// reason (issue 953); another bucket's key is refused, naming it. +func TestViewAnswerKey(t *testing.T) { + hold, _ := HoldAskKey("mesh-mail", fp2of('e')) + key, err := ViewAnswerKey(hold) + if err != nil || key != hold+".view" { + t.Fatalf("ViewAnswerKey = %q, %v", key, err) + } + if back, err := ParseViewAnswerKey(key); err != nil || back != hold { + t.Errorf("ParseViewAnswerKey = %q, %v", back, err) + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := ViewAnswerKey(merge); err == nil || !strings.Contains(err.Error(), "phone") || !strings.Contains(err.Error(), "953") { + t.Errorf("the merge's ask was answerable in the view, or its reason is missing: %v", err) + } + for _, bad := range []string{hold, merge + ".view", "want.ace.mesh-mail.view", "ace.mesh-mail", ""} { + if _, err := ParseViewAnswerKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseViewAnswerKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1c: a view answer round-trips; one naming no option is refused. +func TestViewAnswerRoundTrip(t *testing.T) { + a := ViewAnswer{Option: "release", Words: "fixed by #12"} + data, err := EncodeViewAnswer(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeViewAnswer(data) + a.V = Version + if err != nil || !SameViewAnswer(got, a) { + t.Errorf("view answer came back %+v, %v", got, err) + } + if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil { + t.Errorf("a view answer naming no option was accepted") + } +}