| 1 | package hostrpc |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "io" |
| 9 | "maps" |
| 10 | "slices" |
| 11 | ) |
| 12 | |
| 13 | // ProtocolVersion is the wire revision both sides must agree on in hello. |
| 14 | const ProtocolVersion = 11 |
| 15 | |
| 16 | // Contract is everything the shell needs to call the service: the accepted |
| 17 | // commands, the event names it may receive and every DTO shape they use. |
| 18 | type Contract struct { |
| 19 | ProtocolVersion int `json:"protocolVersion"` |
| 20 | Commands []Command `json:"commands"` |
| 21 | Events []string `json:"events"` |
| 22 | Types map[string]ObjectType `json:"types"` |
| 23 | } |
| 24 | |
| 25 | // Build freezes a registry plus the event names into a Contract. Events are |
| 26 | // sorted and deduplicated so the digest never depends on caller order. |
| 27 | func Build(r *Registry, events []string) Contract { |
| 28 | sorted := slices.Compact(slices.Sorted(slices.Values(events))) |
| 29 | if sorted == nil { |
| 30 | sorted = []string{} |
| 31 | } |
| 32 | types := maps.Clone(r.types) |
| 33 | if types == nil { |
| 34 | types = map[string]ObjectType{} |
| 35 | } |
| 36 | return Contract{ |
| 37 | ProtocolVersion: ProtocolVersion, |
| 38 | Commands: r.Commands(), |
| 39 | Events: sorted, |
| 40 | Types: types, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // Canonical is the contract as sorted-key JSON without whitespace: the bytes |
| 45 | // the digest covers and the shell must reproduce bit for bit. |
| 46 | func (c Contract) Canonical() []byte { |
| 47 | raw, err := json.Marshal(c) |
| 48 | if err != nil { |
| 49 | panic("hostrpc: contract is not JSON-serialisable: " + err.Error()) |
| 50 | } |
| 51 | var generic any |
| 52 | if err := json.Unmarshal(raw, &generic); err != nil { |
| 53 | panic("hostrpc: contract JSON does not round-trip: " + err.Error()) |
| 54 | } |
| 55 | var buf bytes.Buffer |
| 56 | enc := json.NewEncoder(&buf) |
| 57 | enc.SetEscapeHTML(false) |
| 58 | if err := enc.Encode(generic); err != nil { |
| 59 | panic("hostrpc: canonical contract encode: " + err.Error()) |
| 60 | } |
| 61 | return bytes.TrimRight(buf.Bytes(), "\n") |
| 62 | } |
| 63 | |
| 64 | // Digest is "sha256:" plus the hex SHA-256 of Canonical. |
| 65 | func (c Contract) Digest() string { |
| 66 | sum := sha256.Sum256(c.Canonical()) |
| 67 | return "sha256:" + hex.EncodeToString(sum[:]) |
| 68 | } |
| 69 | |
| 70 | // WriteJSON writes the canonical contract indented for review, ending in a |
| 71 | // newline. Key order matches Canonical so the two never disagree. |
| 72 | func WriteJSON(w io.Writer, c Contract) error { |
| 73 | var buf bytes.Buffer |
| 74 | if err := json.Indent(&buf, c.Canonical(), "", " "); err != nil { |
| 75 | return err |
| 76 | } |
| 77 | buf.WriteByte('\n') |
| 78 | _, err := w.Write(buf.Bytes()) |
| 79 | return err |
| 80 | } |
| 81 |