blob: eaa2320b76653313a28e631bcf566ad4ef3665aa [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package sessionflow
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
)
// Writer builds one round in memory, computes its digest, and emits it.
//
// The digest covers every line before the commit frame, so it cannot be known
// until the round is complete - which is why the round is buffered rather than
// streamed. A round is a bounded delta, not a snapshot, so this stays small.
type Writer struct {
buf bytes.Buffer
header Header
counts Counts
closed bool
}
// NewWriter starts a round.
func NewWriter(h Header) (*Writer, error) {
h.T = FrameHeader
h.Schema = Schema
if err := h.Validate(); err != nil {
return nil, err
}
w := &Writer{header: h}
if err := w.emit(h); err != nil {
return nil, err
}
return w, nil
}
// Node appends a node revision.
func (w *Writer) Node(n Node) error {
n.T, n.Revision = FrameNode, w.header.Round
if n.ID == "" {
return fmt.Errorf("sessionflow: node has no id; it could never be superseded")
}
w.counts.Nodes++
return w.emit(n)
}
// Relation appends a relation revision.
func (w *Writer) Relation(r Relation) error {
r.T, r.Revision = FrameRelation, w.header.Round
if r.ID == "" {
return fmt.Errorf("sessionflow: relation has no id; it could never be superseded")
}
w.counts.Relations++
return w.emit(r)
}
// Unresolved appends an unresolved-reference revision.
func (w *Writer) Unresolved(u Unresolved) error {
u.T, u.Revision = FrameUnresolved, w.header.Round
if u.ID == "" {
return fmt.Errorf("sessionflow: unresolved entry has no id; it could never be resolved")
}
if u.State == "" {
u.State = UnresolvedOpen
}
w.counts.Unresolved++
return w.emit(u)
}
// Close writes the commit frame and returns the round's bytes and digest.
//
// A round with no entities is valid: it advances the processed-input watermark,
// which is how a pass that found nothing new still records that it looked.
func (w *Writer) Close() (data []byte, digest string, err error) {
if w.closed {
return nil, "", fmt.Errorf("sessionflow: round already closed")
}
sum := sha256.Sum256(w.buf.Bytes())
digest = hex.EncodeToString(sum[:])
if err := w.emit(Commit{T: FrameCommit, Digest: digest, Counts: w.counts}); err != nil {
return nil, "", err
}
w.closed = true
return w.buf.Bytes(), digest, nil
}
// Counts reports what has been written so far.
func (w *Writer) Counts() Counts { return w.counts }
func (w *Writer) emit(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
w.buf.Write(b)
w.buf.WriteByte('\n')
return nil
}
// Round is a parsed round file.
type Round struct {
Header Header
Nodes []Node
Relations []Relation
Unresolved []Unresolved
Commit Commit
}
// Read parses a round and verifies it.
//
// A round is a shipped artifact, so reading is where a malformed one has to be
// caught - after this point it is folded into a conversation and nothing else
// looks at it. The checks are deliberately strict: anything a well-formed
// producer never emits is rejected rather than tolerated, because tolerating it
// means some consumer somewhere silently disagrees about what the round said.
func Read(r io.Reader) (*Round, error) {
br := bufio.NewReaderSize(r, 1<<20)
var (
out Round
hashed bytes.Buffer
sawHead bool
sawCommit bool
line int
)
ids := map[string]string{}
claim := func(kind, id string) error {
if id == "" {
return fmt.Errorf("sessionflow: line %d: %s frame has no id", line, kind)
}
if prev, dup := ids[id]; dup {
return fmt.Errorf("sessionflow: line %d: id %q appears twice in one round (as %s and %s)",
line, id, prev, kind)
}
ids[id] = kind
return nil
}
for {
raw, err := readLine(br)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
line++
var probe struct {
T FrameType `json:"t"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("sessionflow: line %d: undecodable frame: %w", line, err)
}
if sawCommit {
return nil, fmt.Errorf("sessionflow: line %d: content after the commit frame", line)
}
if line == 1 && probe.T != FrameHeader {
return nil, fmt.Errorf("sessionflow: first frame is %q, not a header", probe.T)
}
if line > 1 && probe.T == FrameHeader {
return nil, fmt.Errorf("sessionflow: line %d: a second header", line)
}
switch probe.T {
case FrameHeader:
if err := json.Unmarshal(raw, &out.Header); err != nil {
return nil, err
}
if err := out.Header.Validate(); err != nil {
return nil, err
}
sawHead = true
case FrameNode:
var n Node
if err := json.Unmarshal(raw, &n); err != nil {
return nil, err
}
if err := claim("node", n.ID); err != nil {
return nil, err
}
if err := checkRevision(line, n.Revision, out.Header.Round); err != nil {
return nil, err
}
if err := checkRefs(line, n.Ref, n.Refs, &out.Header); err != nil {
return nil, err
}
out.Nodes = append(out.Nodes, n)
case FrameRelation:
var v Relation
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if err := claim("relation", v.ID); err != nil {
return nil, err
}
if err := checkRevision(line, v.Revision, out.Header.Round); err != nil {
return nil, err
}
if !v.Tombstone && (v.From == "" || v.To == "" || v.Type == "") {
return nil, fmt.Errorf("sessionflow: line %d: relation %q is missing an endpoint or a type", line, v.ID)
}
if err := checkRefs(line, nil, v.Evidence, &out.Header); err != nil {
return nil, err
}
out.Relations = append(out.Relations, v)
case FrameUnresolved:
var u Unresolved
if err := json.Unmarshal(raw, &u); err != nil {
return nil, err
}
if err := claim("unresolved", u.ID); err != nil {
return nil, err
}
if err := checkRevision(line, u.Revision, out.Header.Round); err != nil {
return nil, err
}
switch u.State {
case UnresolvedOpen, UnresolvedResolved, UnresolvedTerminal:
default:
if !u.Tombstone {
return nil, fmt.Errorf("sessionflow: line %d: unresolved entry %q has state %q", line, u.ID, u.State)
}
}
out.Unresolved = append(out.Unresolved, u)
case FrameCommit:
if err := json.Unmarshal(raw, &out.Commit); err != nil {
return nil, err
}
sawCommit = true
continue // the commit frame is not covered by its own digest
default:
return nil, fmt.Errorf("sessionflow: line %d: unknown frame type %q", line, probe.T)
}
hashed.Write(raw)
hashed.WriteByte('\n')
}
if !sawHead {
return nil, fmt.Errorf("sessionflow: round has no header")
}
if !sawCommit {
return nil, fmt.Errorf("sessionflow: round has no commit frame; it is truncated")
}
sum := sha256.Sum256(hashed.Bytes())
if got := hex.EncodeToString(sum[:]); got != out.Commit.Digest {
return nil, fmt.Errorf("sessionflow: digest mismatch: computed %s, round claims %s",
got[:12], firstN(out.Commit.Digest, 12))
}
want := Counts{Nodes: len(out.Nodes), Relations: len(out.Relations), Unresolved: len(out.Unresolved)}
if want != out.Commit.Counts {
return nil, fmt.Errorf("sessionflow: counts mismatch: read %+v, round claims %+v", want, out.Commit.Counts)
}
return &out, nil
}
// checkRevision enforces that an entity names the round that produced it.
//
// Revision is derived from chain position rather than counted per entity, which
// is what lets a round be re-derived without replaying the chain. A frame whose
// revision disagrees with its header was not produced by that round.
func checkRevision(line int, got, round uint64) error {
if got != round {
return fmt.Errorf("sessionflow: line %d: revision %d in round %d", line, got, round)
}
return nil
}
// checkRefs rejects references outside the range the header declares it read.
//
// A round says which landed sequences it consumed. A reference past that range
// describes evidence the round did not claim to have seen, and its input digest
// therefore does not cover it.
func checkRefs(line int, one *Ref, many []Ref, h *Header) error {
all := many
if one != nil {
all = append(append([]Ref(nil), *one), many...)
}
for _, r := range all {
if r.Seq == 0 && r.Row == 0 {
return fmt.Errorf("sessionflow: line %d: a reference to seq 0 row 0 is not a position", line)
}
if r.Seq > h.ThroughSeq {
return fmt.Errorf("sessionflow: line %d: reference to landed sequence %d, past the round's declared %d",
line, r.Seq, h.ThroughSeq)
}
}
return nil
}
func readLine(br *bufio.Reader) ([]byte, error) {
line, err := br.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) && len(line) > 0 {
return line, nil
}
return nil, err
}
return line[:len(line)-1], nil
}
func firstN(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}