From 3ac971461322ae0cc36f373b5189ca5825432243 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Tue, 8 Sep 2026 18:41:30 +0100 Subject: [PATCH 1/2] feat: add routing policy commands --- commands/internal/codec/codec.go | 195 +++++++++++++++++++++ commands/routing/cbor_gen.go | 291 +++++++++++++++++++++++++++++++ commands/routing/codec.go | 99 +++++++++++ commands/routing/codec_test.go | 73 ++++++++ commands/routing/gen/main.go | 45 +++++ commands/routing/json_gen.go | 289 ++++++++++++++++++++++++++++++ commands/routing/put.go | 28 +++ commands/routing/types.go | 46 +++++ commands/routing/use.go | 33 ++++ commands/s3/codec.go | 246 ++++---------------------- 10 files changed, 1137 insertions(+), 208 deletions(-) create mode 100644 commands/internal/codec/codec.go create mode 100644 commands/routing/cbor_gen.go create mode 100644 commands/routing/codec.go create mode 100644 commands/routing/codec_test.go create mode 100644 commands/routing/gen/main.go create mode 100644 commands/routing/json_gen.go create mode 100644 commands/routing/put.go create mode 100644 commands/routing/types.go create mode 100644 commands/routing/use.go diff --git a/commands/internal/codec/codec.go b/commands/internal/codec/codec.go new file mode 100644 index 0000000..0ed8dd6 --- /dev/null +++ b/commands/internal/codec/codec.go @@ -0,0 +1,195 @@ +// Package codec holds the CBOR and DAG-JSON primitives shared by the +// hand-written codecs in the commands packages. Those codecs exist for map +// types the cbor-gen / dag-json-gen generators cannot produce: DID- or +// CID-keyed maps and slice-valued maps. +package codec + +import ( + "fmt" + "io" + "sort" + + jsg "github.com/alanshaw/dag-json-gen" + "github.com/fil-forge/ucantone/did" + cid "github.com/ipfs/go-cid" + cbg "github.com/whyrusleeping/cbor-gen" + xerrors "golang.org/x/xerrors" +) + +// Size limits applied when encoding and decoding, matching the generators' +// defaults. +const ( + MaxString = 8192 + MaxLen = 4096 +) + +// --- CBOR --- + +// WriteCborString writes s as a CBOR text string. +func WriteCborString(cw *cbg.CborWriter, s string) error { + if len(s) > MaxString { + return xerrors.Errorf("string value was too long (%d)", len(s)) + } + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(s))); err != nil { + return err + } + _, err := cw.WriteString(s) + return err +} + +// ReadCborString reads a CBOR text string of at most [MaxString] bytes. +func ReadCborString(cr *cbg.CborReader) (string, error) { + return cbg.ReadStringWithMax(cr, MaxString) +} + +// WriteCborArrayHeader writes the header of a CBOR array with n elements. +func WriteCborArrayHeader(cw *cbg.CborWriter, n int) error { + if n > MaxString { + return xerrors.Errorf("slice value was too long (%d)", n) + } + return cw.WriteMajorTypeHeader(cbg.MajArray, uint64(n)) +} + +// ReadCborArrayHeader reads a CBOR array header and returns its length. +func ReadCborArrayHeader(cr *cbg.CborReader) (uint64, error) { + maj, extra, err := cr.ReadHeader() + if err != nil { + return 0, err + } + if maj != cbg.MajArray { + return 0, fmt.Errorf("expected cbor array") + } + if extra > MaxString { + return 0, fmt.Errorf("array too large (%d)", extra) + } + return extra, nil +} + +// WriteCborMapHeader writes the header of a CBOR map with n entries. +func WriteCborMapHeader(cw *cbg.CborWriter, n int) error { + if n > MaxLen { + return xerrors.Errorf("map too large (%d)", n) + } + return cw.WriteMajorTypeHeader(cbg.MajMap, uint64(n)) +} + +// ReadCborMapHeader reads a CBOR map header and returns its entry count. +func ReadCborMapHeader(cr *cbg.CborReader) (uint64, error) { + maj, extra, err := cr.ReadHeader() + if err != nil { + return 0, err + } + if maj != cbg.MajMap { + return 0, fmt.Errorf("expected a map (major type 5)") + } + if extra > MaxLen { + return 0, fmt.Errorf("map too large (%d)", extra) + } + return extra, nil +} + +// SortedDIDs returns the DID keys of m sorted by their string encoding, so the +// encoded map keys come out in the same (lexicographic) order the generators +// use for string-keyed maps. +func SortedDIDs[V any](m map[did.DID]V) []did.DID { + keys := make([]did.DID, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + return keys +} + +// SortedCIDs returns the CID keys of m sorted by their string encoding. +func SortedCIDs[V any](m map[cid.Cid]V) []cid.Cid { + keys := make([]cid.Cid, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + return keys +} + +// --- DAG-JSON --- + +// WriteJSONKey writes an object key, preceded by a comma when comma is true +// (i.e. when it is not the first entry of the object). +func WriteJSONKey(jw *jsg.DagJsonWriter, name string, comma bool) error { + if comma { + if err := jw.WriteComma(); err != nil { + return err + } + } + if err := jw.WriteString(name); err != nil { + return err + } + return jw.WriteObjectColon() +} + +// ReadJSONObject reads a DAG-JSON object, invoking fn for each entry after +// consuming its key and colon. fn is responsible for reading the value. +func ReadJSONObject(jr *jsg.DagJsonReader, fn func(name string) error) (err error) { + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return err + } + close, err := jr.PeekObjectClose() + if err != nil { + return err + } + if close { + return jr.ReadObjectClose() + } + for { + name, err := jr.ReadString(MaxString) + if err != nil { + return err + } + if err := jr.ReadObjectColon(); err != nil { + return err + } + if err := fn(name); err != nil { + return err + } + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return err + } + if close { + return nil + } + } +} + +// ReadJSONArray reads a DAG-JSON array, invoking read for each element. +func ReadJSONArray[T any](jr *jsg.DagJsonReader, read func() (T, error)) ([]T, error) { + if err := jr.ReadArrayOpen(); err != nil { + return nil, err + } + close, err := jr.PeekArrayClose() + if err != nil { + return nil, err + } + var out []T + if close { + return out, jr.ReadArrayClose() + } + for { + v, err := read() + if err != nil { + return nil, err + } + out = append(out, v) + close, err := jr.ReadArrayCloseOrComma() + if err != nil { + return nil, err + } + if close { + return out, nil + } + } +} diff --git a/commands/routing/cbor_gen.go b/commands/routing/cbor_gen.go new file mode 100644 index 0000000..57ebe13 --- /dev/null +++ b/commands/routing/cbor_gen.go @@ -0,0 +1,291 @@ +//go:build !codegen + +// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. + +package routing + +import ( + "fmt" + "io" + "math" + "sort" + + did "github.com/fil-forge/ucantone/did" + cid "github.com/ipfs/go-cid" + cbg "github.com/whyrusleeping/cbor-gen" + xerrors "golang.org/x/xerrors" +) + +var _ = xerrors.Errorf +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort + +func (t *Candidate) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{160}); err != nil { + return err + } + return nil +} + +func (t *Candidate) UnmarshalCBOR(r io.Reader) (err error) { + *t = Candidate{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Candidate: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 0) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *PutArguments) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{161}); err != nil { + return err + } + + // t.Candidates (routing.CandidateSet) (struct) + if len("candidates") > 8192 { + return xerrors.Errorf("Value in field \"candidates\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("candidates"))); err != nil { + return err + } + if _, err := cw.WriteString(string("candidates")); err != nil { + return err + } + + if err := t.Candidates.MarshalCBOR(cw); err != nil { + return err + } + return nil +} + +func (t *PutArguments) UnmarshalCBOR(r io.Reader) (err error) { + *t = PutArguments{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("PutArguments: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 10) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Candidates (routing.CandidateSet) (struct) + case "candidates": + + { + + if err := t.Candidates.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Candidates: %w", err) + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *UseArguments) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 1 + + if t.Policy == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Policy (did.DID) (struct) + if t.Policy != nil { + + if len("policy") > 8192 { + return xerrors.Errorf("Value in field \"policy\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("policy"))); err != nil { + return err + } + if _, err := cw.WriteString(string("policy")); err != nil { + return err + } + + if err := t.Policy.MarshalCBOR(cw); err != nil { + return err + } + } + return nil +} + +func (t *UseArguments) UnmarshalCBOR(r io.Reader) (err error) { + *t = UseArguments{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("UseArguments: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 6) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Policy (did.DID) (struct) + case "policy": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Policy = new(did.DID) + if err := t.Policy.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Policy pointer: %w", err) + } + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/commands/routing/codec.go b/commands/routing/codec.go new file mode 100644 index 0000000..19bcc37 --- /dev/null +++ b/commands/routing/codec.go @@ -0,0 +1,99 @@ +//go:build !codegen + +package routing + +import ( + "io" + + jsg "github.com/alanshaw/dag-json-gen" + "github.com/fil-forge/libforge/commands/internal/codec" + "github.com/fil-forge/ucantone/did" + cbg "github.com/whyrusleeping/cbor-gen" + xerrors "golang.org/x/xerrors" +) + +// CandidateSet has a hand-written codec because cbor-gen / dag-json-gen do not +// support DID-keyed maps. The CBOR and DAG-JSON primitives live in the shared +// internal codec package. + +func (t CandidateSet) MarshalCBOR(w io.Writer) error { + cw := cbg.NewCborWriter(w) + if err := codec.WriteCborMapHeader(cw, len(t.Entries)); err != nil { + return err + } + for _, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteCborString(cw, k.String()); err != nil { + return err + } + c := t.Entries[k] + if err := c.MarshalCBOR(cw); err != nil { + return err + } + } + return nil +} + +func (t *CandidateSet) UnmarshalCBOR(r io.Reader) error { + cr := cbg.NewCborReader(r) + n, err := codec.ReadCborMapHeader(cr) + if err != nil { + return err + } + m := make(map[did.DID]Candidate, n) + for i := uint64(0); i < n; i++ { + ks, err := codec.ReadCborString(cr) + if err != nil { + return err + } + k, err := did.Parse(ks) + if err != nil { + return xerrors.Errorf("parsing candidate did %q: %w", ks, err) + } + var c Candidate + if err := c.UnmarshalCBOR(cr); err != nil { + return err + } + m[k] = c + } + *t = CandidateSet{Entries: m} + return nil +} + +func (t CandidateSet) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if err := jw.WriteObjectOpen(); err != nil { + return err + } + for i, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteJSONKey(jw, k.String(), i > 0); err != nil { + return err + } + c := t.Entries[k] + if err := c.MarshalDagJSON(jw); err != nil { + return err + } + } + return jw.WriteObjectClose() +} + +func (t *CandidateSet) UnmarshalDagJSON(r io.Reader) error { + jr := jsg.NewDagJsonReader(r) + m := map[did.DID]Candidate{} + err := codec.ReadJSONObject(jr, func(ks string) error { + k, err := did.Parse(ks) + if err != nil { + return xerrors.Errorf("parsing candidate did %q: %w", ks, err) + } + var c Candidate + if err := c.UnmarshalDagJSON(jr); err != nil { + return err + } + m[k] = c + return nil + }) + if err != nil { + return err + } + *t = CandidateSet{Entries: m} + return nil +} diff --git a/commands/routing/codec_test.go b/commands/routing/codec_test.go new file mode 100644 index 0000000..da9095a --- /dev/null +++ b/commands/routing/codec_test.go @@ -0,0 +1,73 @@ +//go:build !codegen + +package routing_test + +import ( + "bytes" + "io" + "reflect" + "testing" + + "github.com/fil-forge/libforge/commands/routing" + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +var ( + nodeA = did.MustParse("did:key:z6MkjFRxLLGdBqQSLkZbVnuwUFiomK8eGBkPtim9ETvP7vec") + nodeB = did.MustParse("did:key:z6MkmNBgCewjYfEDTdKLpHkbMWUogJk29CxmiVdLeW4Kz3UG") +) + +type wire interface { + MarshalCBOR(w io.Writer) error + UnmarshalCBOR(r io.Reader) error + MarshalDagJSON(w io.Writer) error + UnmarshalDagJSON(r io.Reader) error +} + +// roundTrip encodes in as CBOR and DAG-JSON, decodes each and asserts equality. +// It returns the DAG-JSON encoding. +func roundTrip[T any, PT interface { + *T + wire +}](t *testing.T, in PT) string { + t.Helper() + var cb bytes.Buffer + require.NoError(t, in.MarshalCBOR(&cb)) + outCBOR := PT(new(T)) + require.NoError(t, outCBOR.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.True(t, reflect.DeepEqual(*in, *outCBOR), "CBOR round-trip mismatch:\n got %#v\nwant %#v", *outCBOR, *in) + + var jb bytes.Buffer + require.NoError(t, in.MarshalDagJSON(&jb)) + outJSON := PT(new(T)) + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes())), "json: %s", jb.String()) + require.True(t, reflect.DeepEqual(*in, *outJSON), "DAG-JSON round-trip mismatch:\n got %#v\nwant %#v", *outJSON, *in) + return jb.String() +} + +func TestPutArgumentsRoundTrip(t *testing.T) { + in := &routing.PutArguments{Candidates: routing.CandidateSet{Entries: map[did.DID]routing.Candidate{ + nodeB: {}, + nodeA: {}, + }}} + // Keys are sorted and each candidate is an empty object. + require.Equal(t, `{"candidates":{"`+nodeA.String()+`":{},"`+nodeB.String()+`":{}}}`, roundTrip(t, in)) +} + +func TestCandidateSetEmpty(t *testing.T) { + in := &routing.CandidateSet{Entries: map[did.DID]routing.Candidate{}} + require.Equal(t, `{}`, roundTrip(t, in)) +} + +func TestCandidateSetRejectsInvalidDID(t *testing.T) { + var out routing.CandidateSet + require.Error(t, out.UnmarshalDagJSON(bytes.NewReader([]byte(`{"not-a-did":{}}`)))) +} + +func TestUseArgumentsRoundTrip(t *testing.T) { + policy := nodeA + require.Equal(t, `{"policy":"`+policy.String()+`"}`, roundTrip(t, &routing.UseArguments{Policy: &policy})) + // Absent policy omits the key: this is the "clear" form. + require.Equal(t, `{}`, roundTrip(t, &routing.UseArguments{})) +} diff --git a/commands/routing/gen/main.go b/commands/routing/gen/main.go new file mode 100644 index 0000000..7e81411 --- /dev/null +++ b/commands/routing/gen/main.go @@ -0,0 +1,45 @@ +//go:generate go run -tags codegen . + +package main + +import ( + "os" + + jsg "github.com/alanshaw/dag-json-gen" + "github.com/fil-forge/libforge/commands/routing" + cbg "github.com/whyrusleeping/cbor-gen" +) + +const buildTag = "//go:build !codegen\n\n" + +func tag(path string) { + data, err := os.ReadFile(path) + if err != nil { + panic(err) + } + if err := os.WriteFile(path, append([]byte(buildTag), data...), 0644); err != nil { + panic(err) + } +} + +func main() { + // CandidateSet has a hand-written codec (see codec.go) because cbor-gen / + // dag-json-gen do not support DID-keyed maps. + models := []any{ + routing.Candidate{}, + routing.PutArguments{}, + routing.UseArguments{}, + } + const ( + cborFile = "../cbor_gen.go" + jsonFile = "../json_gen.go" + ) + if err := cbg.WriteMapEncodersToFile(cborFile, "routing", models...); err != nil { + panic(err) + } + if err := jsg.WriteMapEncodersToFile(jsonFile, "routing", models...); err != nil { + panic(err) + } + tag(cborFile) + tag(jsonFile) +} diff --git a/commands/routing/json_gen.go b/commands/routing/json_gen.go new file mode 100644 index 0000000..fdce562 --- /dev/null +++ b/commands/routing/json_gen.go @@ -0,0 +1,289 @@ +//go:build !codegen + +// Code generated by github.com/alanshaw/dag-json-gen. DO NOT EDIT. + +package routing + +import ( + "errors" + "fmt" + "io" + "math" + "sort" + + jsg "github.com/alanshaw/dag-json-gen" + did "github.com/fil-forge/ucantone/did" + cid "github.com/ipfs/go-cid" +) + +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort +var _ = errors.Is + +func (t *Candidate) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *Candidate) UnmarshalDagJSON(r io.Reader) (err error) { + *t = Candidate{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for Candidate: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for Candidate: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for Candidate: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field Candidate: string too large") + } + return fmt.Errorf("reading string for field Candidate: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field Candidate: %w", err) + } + switch name { + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for Candidate: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field Candidate: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for Candidate") + } + } + } + + return nil +} +func (t *PutArguments) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + + // t.Candidates (routing.CandidateSet) (struct) + if len("candidates") > 8192 { + return fmt.Errorf("string in field \"candidates\" was too long") + } + if err := jw.WriteString(string("candidates")); err != nil { + return fmt.Errorf("writing string for field \"candidates\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Candidates.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field t.Candidates: %w", err) + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *PutArguments) UnmarshalDagJSON(r io.Reader) (err error) { + *t = PutArguments{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for PutArguments: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for PutArguments: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for PutArguments: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field PutArguments: string too large") + } + return fmt.Errorf("reading string for field PutArguments: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field PutArguments: %w", err) + } + switch name { + + // t.Candidates (routing.CandidateSet) (struct) + case "candidates": + + if err := t.Candidates.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Candidates: %w", err) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for PutArguments: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field PutArguments: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for PutArguments") + } + } + } + + return nil +} +func (t *UseArguments) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + + // t.Policy (did.DID) (struct) + if t.Policy != nil { + if len("policy") > 8192 { + return fmt.Errorf("string in field \"policy\" was too long") + } + if err := jw.WriteString(string("policy")); err != nil { + return fmt.Errorf("writing string for field \"policy\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Policy.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("marshaling field t.Policy: %w", err) + } + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *UseArguments) UnmarshalDagJSON(r io.Reader) (err error) { + *t = UseArguments{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("reading object open for UseArguments: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("peeking object close for UseArguments: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("reading object close for UseArguments: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("reading string for field UseArguments: string too large") + } + return fmt.Errorf("reading string for field UseArguments: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("reading object colon for field UseArguments: %w", err) + } + switch name { + + // t.Policy (did.DID) (struct) + case "policy": + + { + null, err := jr.PeekNull() + if err != nil { + return fmt.Errorf("peeking null for field t.Policy: %w", err) + } + if null { + if err := jr.ReadNull(); err != nil { + return fmt.Errorf("reading null for field t.Policy: %w", err) + } + } else { + t.Policy = new(did.DID) + if err := t.Policy.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Policy pointer: %w", err) + } + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ignoring field %s for UseArguments: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("reading object close or comma for field UseArguments: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("map too large for UseArguments") + } + } + } + + return nil +} diff --git a/commands/routing/put.go b/commands/routing/put.go new file mode 100644 index 0000000..a9d6a6a --- /dev/null +++ b/commands/routing/put.go @@ -0,0 +1,28 @@ +//go:build !codegen + +package routing + +import ( + "github.com/fil-forge/libforge/commands" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/ucan/command" +) + +type PutOK = commands.Unit + +// Put (/routing/put) replaces the candidate set of a routing policy. The +// subject is the policy DID. A policy needs no registration step: it exists in +// the upload service once its first put succeeds. +// +// Every candidate MUST identify a storage node registered with the upload +// service and the set MUST NOT be empty; otherwise the invocation fails with +// InvalidCandidates. A change applies to writes routed after it takes effect; +// in-flight writes MAY be routed using the previous set. +// +// The receipt carries no payload (Unit). +var Put = binding.Bind[*PutArguments, *PutOK](command.MustParse("/routing/put")) + +// InvalidCandidatesErrorName is the stable receipt-failure name when a put +// carries an empty candidate set or a candidate that is not a registered +// storage node. +const InvalidCandidatesErrorName = "InvalidCandidates" diff --git a/commands/routing/types.go b/commands/routing/types.go new file mode 100644 index 0000000..ddd370f --- /dev/null +++ b/commands/routing/types.go @@ -0,0 +1,46 @@ +// Package routing defines the routing policy UCAN commands served by the +// upload service. A routing policy is a did:key that owns a set of storage +// node DIDs (its candidates). A space that references a policy has every +// `/blob/add` routed to one of the policy's candidates, whoever invoked the +// write. +// +// CandidateSet is a DID-keyed map. cbor-gen / dag-json-gen only generate +// string-keyed maps, so it has a hand-written codec (see codec.go) and is +// defined as a struct wrapping the map so that structs embedding it can still +// be generated as usual. +package routing + +import "github.com/fil-forge/ucantone/did" + +// Candidate holds the per-node properties of a routing policy candidate. It is +// currently empty and reserved for properties that influence routing decisions +// (e.g. weight). +type Candidate struct{} + +// CandidateSet maps a storage node DID to its [Candidate] properties. The map +// is a set: encoding is unordered and dag-cbor sorts the keys. The DID is +// encoded as the (string) map key on the wire. +// +// It is a struct wrapping the map (rather than a named map type) so that +// structs embedding it can be generated by cbor-gen / dag-json-gen, which +// delegate to a struct field's MarshalCBOR but cannot generate a DID-keyed map +// inline. On the wire it encodes as the bare map, not as an object with an +// "entries" key. +type CandidateSet struct { + Entries map[did.DID]Candidate +} + +// PutArguments are the arguments to the `/routing/put` command. +type PutArguments struct { + // Candidates is the complete candidate set of the policy. It replaces any + // previously stored set and MUST contain at least one entry. + Candidates CandidateSet `cborgen:"candidates" dagjsongen:"candidates"` +} + +// UseArguments are the arguments to the `/routing/use` command. +type UseArguments struct { + // Policy is the DID of the routing policy the space should use. When + // absent the space's policy reference is cleared and the space returns to + // default routing. + Policy *did.DID `cborgen:"policy,omitempty" dagjsongen:"policy,omitempty"` +} diff --git a/commands/routing/use.go b/commands/routing/use.go new file mode 100644 index 0000000..45c783f --- /dev/null +++ b/commands/routing/use.go @@ -0,0 +1,33 @@ +//go:build !codegen + +package routing + +import ( + "github.com/fil-forge/libforge/commands" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/ucan/command" +) + +type UseOK = commands.Unit + +// Use (/routing/use) sets or clears the routing policy a space references. The +// subject is the space. With a policy, every subsequent `/blob/add` on the +// space is routed to one of the policy's candidates and fails with +// CandidateUnavailable when none can serve it. Without a policy the reference +// is cleared and the space returns to default routing. +// +// The space MUST be provisioned with a provider (SpaceNotProvisioned) and the +// policy MUST be known to the upload service, i.e. have a stored candidate set +// (UnknownPolicy). +// +// The receipt carries no payload (Unit). +var Use = binding.Bind[*UseArguments, *UseOK](command.MustParse("/routing/use")) + +const ( + // UnknownPolicyErrorName is the stable receipt-failure name when the + // referenced policy has no stored candidate set. + UnknownPolicyErrorName = "UnknownPolicy" + // SpaceNotProvisionedErrorName is the stable receipt-failure name when the + // subject space has no service provider. + SpaceNotProvisionedErrorName = "SpaceNotProvisioned" +) diff --git a/commands/s3/codec.go b/commands/s3/codec.go index 5e32c17..811a729 100644 --- a/commands/s3/codec.go +++ b/commands/s3/codec.go @@ -3,11 +3,10 @@ package s3 import ( - "fmt" "io" - "sort" jsg "github.com/alanshaw/dag-json-gen" + "github.com/fil-forge/libforge/commands/internal/codec" "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" cbg "github.com/whyrusleeping/cbor-gen" @@ -15,111 +14,26 @@ import ( ) // These codecs are hand-written because cbor-gen / dag-json-gen do not support -// slice-valued maps. - -const ( - maxString = 8192 - maxLen = 4096 -) - -// --- shared CBOR helpers --- - -func writeCborStringField(cw *cbg.CborWriter, s string) error { - if len(s) > maxString { - return xerrors.Errorf("string value was too long (%d)", len(s)) - } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(s))); err != nil { - return err - } - _, err := cw.WriteString(s) - return err -} - -func readCborStringField(cr *cbg.CborReader) (string, error) { - return cbg.ReadStringWithMax(cr, maxString) -} - -func writeCborArrayHeader(cw *cbg.CborWriter, n int) error { - if n > maxString { - return xerrors.Errorf("slice value was too long (%d)", n) - } - return cw.WriteMajorTypeHeader(cbg.MajArray, uint64(n)) -} - -func readCborArrayHeader(cr *cbg.CborReader) (uint64, error) { - maj, extra, err := cr.ReadHeader() - if err != nil { - return 0, err - } - if maj != cbg.MajArray { - return 0, fmt.Errorf("expected cbor array") - } - if extra > maxString { - return 0, fmt.Errorf("array too large (%d)", extra) - } - return extra, nil -} - -func writeCborMapHeader(cw *cbg.CborWriter, n int) error { - if n > maxLen { - return xerrors.Errorf("map too large (%d)", n) - } - return cw.WriteMajorTypeHeader(cbg.MajMap, uint64(n)) -} - -func readCborMapHeader(cr *cbg.CborReader) (uint64, error) { - maj, extra, err := cr.ReadHeader() - if err != nil { - return 0, err - } - if maj != cbg.MajMap { - return 0, fmt.Errorf("expected a map (major type 5)") - } - if extra > maxLen { - return 0, fmt.Errorf("map too large (%d)", extra) - } - return extra, nil -} - -// sortedDIDs returns the DID keys of m sorted by their string encoding, so the -// encoded map keys come out in the same (lexicographic) order the generators -// use for string-keyed maps. -func sortedDIDs[V any](m map[did.DID]V) []did.DID { - keys := make([]did.DID, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) - return keys -} - -// sortedCIDs returns the CID keys of m sorted by their string encoding. -func sortedCIDs[V any](m map[cid.Cid]V) []cid.Cid { - keys := make([]cid.Cid, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) - return keys -} +// slice-valued maps. The CBOR and DAG-JSON primitives live in the shared +// internal codec package. // --- PermissionSet: map[did.DID][]string --- func (t PermissionSet) MarshalCBOR(w io.Writer) error { cw := cbg.NewCborWriter(w) - if err := writeCborMapHeader(cw, len(t.Entries)); err != nil { + if err := codec.WriteCborMapHeader(cw, len(t.Entries)); err != nil { return err } - for _, k := range sortedDIDs(t.Entries) { - if err := writeCborStringField(cw, k.String()); err != nil { + for _, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteCborString(cw, k.String()); err != nil { return err } perms := t.Entries[k] - if err := writeCborArrayHeader(cw, len(perms)); err != nil { + if err := codec.WriteCborArrayHeader(cw, len(perms)); err != nil { return err } for _, p := range perms { - if err := writeCborStringField(cw, p); err != nil { + if err := codec.WriteCborString(cw, p); err != nil { return err } } @@ -129,13 +43,13 @@ func (t PermissionSet) MarshalCBOR(w io.Writer) error { func (t *PermissionSet) UnmarshalCBOR(r io.Reader) error { cr := cbg.NewCborReader(r) - n, err := readCborMapHeader(cr) + n, err := codec.ReadCborMapHeader(cr) if err != nil { return err } m := make(map[did.DID][]string, n) for i := uint64(0); i < n; i++ { - ks, err := readCborStringField(cr) + ks, err := codec.ReadCborString(cr) if err != nil { return err } @@ -143,13 +57,13 @@ func (t *PermissionSet) UnmarshalCBOR(r io.Reader) error { if err != nil { return xerrors.Errorf("parsing access key did %q: %w", ks, err) } - alen, err := readCborArrayHeader(cr) + alen, err := codec.ReadCborArrayHeader(cr) if err != nil { return err } perms := make([]string, 0, alen) for j := uint64(0); j < alen; j++ { - p, err := readCborStringField(cr) + p, err := codec.ReadCborString(cr) if err != nil { return err } @@ -166,8 +80,8 @@ func (t PermissionSet) MarshalDagJSON(w io.Writer) error { if err := jw.WriteObjectOpen(); err != nil { return err } - for i, k := range sortedDIDs(t.Entries) { - if err := writeJSONKey(jw, k.String(), i > 0); err != nil { + for i, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteJSONKey(jw, k.String(), i > 0); err != nil { return err } if err := jw.WriteArrayOpen(); err != nil { @@ -193,12 +107,12 @@ func (t PermissionSet) MarshalDagJSON(w io.Writer) error { func (t *PermissionSet) UnmarshalDagJSON(r io.Reader) error { jr := jsg.NewDagJsonReader(r) m := map[did.DID][]string{} - err := readJSONObject(jr, func(ks string) error { + err := codec.ReadJSONObject(jr, func(ks string) error { k, err := did.Parse(ks) if err != nil { return xerrors.Errorf("parsing access key did %q: %w", ks, err) } - perms, err := readJSONArray(jr, func() (string, error) { return jr.ReadString(maxString) }) + perms, err := codec.ReadJSONArray(jr, func() (string, error) { return jr.ReadString(codec.MaxString) }) if err != nil { return err } @@ -216,15 +130,15 @@ func (t *PermissionSet) UnmarshalDagJSON(r io.Reader) error { func (t ProofSet) MarshalCBOR(w io.Writer) error { cw := cbg.NewCborWriter(w) - if err := writeCborMapHeader(cw, len(t.Entries)); err != nil { + if err := codec.WriteCborMapHeader(cw, len(t.Entries)); err != nil { return err } - for _, k := range sortedCIDs(t.Entries) { - if err := writeCborStringField(cw, k.String()); err != nil { + for _, k := range codec.SortedCIDs(t.Entries) { + if err := codec.WriteCborString(cw, k.String()); err != nil { return err } links := t.Entries[k] - if err := writeCborArrayHeader(cw, len(links)); err != nil { + if err := codec.WriteCborArrayHeader(cw, len(links)); err != nil { return err } for _, c := range links { @@ -238,13 +152,13 @@ func (t ProofSet) MarshalCBOR(w io.Writer) error { func (t *ProofSet) UnmarshalCBOR(r io.Reader) error { cr := cbg.NewCborReader(r) - n, err := readCborMapHeader(cr) + n, err := codec.ReadCborMapHeader(cr) if err != nil { return err } m := make(map[cid.Cid][]cid.Cid, n) for i := uint64(0); i < n; i++ { - ks, err := readCborStringField(cr) + ks, err := codec.ReadCborString(cr) if err != nil { return err } @@ -252,7 +166,7 @@ func (t *ProofSet) UnmarshalCBOR(r io.Reader) error { if err != nil { return xerrors.Errorf("parsing delegation cid %q: %w", ks, err) } - alen, err := readCborArrayHeader(cr) + alen, err := codec.ReadCborArrayHeader(cr) if err != nil { return err } @@ -275,8 +189,8 @@ func (t ProofSet) MarshalDagJSON(w io.Writer) error { if err := jw.WriteObjectOpen(); err != nil { return err } - for i, k := range sortedCIDs(t.Entries) { - if err := writeJSONKey(jw, k.String(), i > 0); err != nil { + for i, k := range codec.SortedCIDs(t.Entries) { + if err := codec.WriteJSONKey(jw, k.String(), i > 0); err != nil { return err } if err := jw.WriteArrayOpen(); err != nil { @@ -302,12 +216,12 @@ func (t ProofSet) MarshalDagJSON(w io.Writer) error { func (t *ProofSet) UnmarshalDagJSON(r io.Reader) error { jr := jsg.NewDagJsonReader(r) m := map[cid.Cid][]cid.Cid{} - err := readJSONObject(jr, func(ks string) error { + err := codec.ReadJSONObject(jr, func(ks string) error { k, err := cid.Decode(ks) if err != nil { return xerrors.Errorf("parsing delegation cid %q: %w", ks, err) } - links, err := readJSONArray(jr, func() (cid.Cid, error) { return jr.ReadCid() }) + links, err := codec.ReadJSONArray(jr, func() (cid.Cid, error) { return jr.ReadCid() }) if err != nil { return err } @@ -325,15 +239,15 @@ func (t *ProofSet) UnmarshalDagJSON(r io.Reader) error { func (t KeySet) MarshalCBOR(w io.Writer) error { cw := cbg.NewCborWriter(w) - if err := writeCborMapHeader(cw, len(t.Entries)); err != nil { + if err := codec.WriteCborMapHeader(cw, len(t.Entries)); err != nil { return err } - for _, k := range sortedDIDs(t.Entries) { - if err := writeCborStringField(cw, k.String()); err != nil { + for _, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteCborString(cw, k.String()); err != nil { return err } keys := t.Entries[k] - if err := writeCborArrayHeader(cw, len(keys)); err != nil { + if err := codec.WriteCborArrayHeader(cw, len(keys)); err != nil { return err } for i := range keys { @@ -347,13 +261,13 @@ func (t KeySet) MarshalCBOR(w io.Writer) error { func (t *KeySet) UnmarshalCBOR(r io.Reader) error { cr := cbg.NewCborReader(r) - n, err := readCborMapHeader(cr) + n, err := codec.ReadCborMapHeader(cr) if err != nil { return err } m := make(map[did.DID][]VerificationKey, n) for i := uint64(0); i < n; i++ { - ks, err := readCborStringField(cr) + ks, err := codec.ReadCborString(cr) if err != nil { return err } @@ -361,7 +275,7 @@ func (t *KeySet) UnmarshalCBOR(r io.Reader) error { if err != nil { return xerrors.Errorf("parsing access key did %q: %w", ks, err) } - alen, err := readCborArrayHeader(cr) + alen, err := codec.ReadCborArrayHeader(cr) if err != nil { return err } @@ -382,8 +296,8 @@ func (t KeySet) MarshalDagJSON(w io.Writer) error { if err := jw.WriteObjectOpen(); err != nil { return err } - for i, k := range sortedDIDs(t.Entries) { - if err := writeJSONKey(jw, k.String(), i > 0); err != nil { + for i, k := range codec.SortedDIDs(t.Entries) { + if err := codec.WriteJSONKey(jw, k.String(), i > 0); err != nil { return err } if err := jw.WriteArrayOpen(); err != nil { @@ -410,12 +324,12 @@ func (t KeySet) MarshalDagJSON(w io.Writer) error { func (t *KeySet) UnmarshalDagJSON(r io.Reader) error { jr := jsg.NewDagJsonReader(r) m := map[did.DID][]VerificationKey{} - err := readJSONObject(jr, func(ks string) error { + err := codec.ReadJSONObject(jr, func(ks string) error { k, err := did.Parse(ks) if err != nil { return xerrors.Errorf("parsing access key did %q: %w", ks, err) } - keys, err := readJSONArray(jr, func() (VerificationKey, error) { + keys, err := codec.ReadJSONArray(jr, func() (VerificationKey, error) { var vk VerificationKey err := vk.UnmarshalDagJSON(jr) return vk, err @@ -432,87 +346,3 @@ func (t *KeySet) UnmarshalDagJSON(r io.Reader) error { *t = KeySet{Entries: m} return nil } - -// --- shared DAG-JSON helpers --- - -// writeJSONKey writes an object key, preceded by a comma when comma is true -// (i.e. when it is not the first entry of the object). -func writeJSONKey(jw *jsg.DagJsonWriter, name string, comma bool) error { - if comma { - if err := jw.WriteComma(); err != nil { - return err - } - } - if err := jw.WriteString(name); err != nil { - return err - } - return jw.WriteObjectColon() -} - -// readJSONObject reads a DAG-JSON object, invoking fn for each entry after -// consuming its key and colon. fn is responsible for reading the value. -func readJSONObject(jr *jsg.DagJsonReader, fn func(name string) error) (err error) { - defer func() { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - }() - if err := jr.ReadObjectOpen(); err != nil { - return err - } - close, err := jr.PeekObjectClose() - if err != nil { - return err - } - if close { - return jr.ReadObjectClose() - } - for { - name, err := jr.ReadString(maxString) - if err != nil { - return err - } - if err := jr.ReadObjectColon(); err != nil { - return err - } - if err := fn(name); err != nil { - return err - } - close, err := jr.ReadObjectCloseOrComma() - if err != nil { - return err - } - if close { - return nil - } - } -} - -// readJSONArray reads a DAG-JSON array, invoking read for each element. -func readJSONArray[T any](jr *jsg.DagJsonReader, read func() (T, error)) ([]T, error) { - if err := jr.ReadArrayOpen(); err != nil { - return nil, err - } - close, err := jr.PeekArrayClose() - if err != nil { - return nil, err - } - var out []T - if close { - return out, jr.ReadArrayClose() - } - for { - v, err := read() - if err != nil { - return nil, err - } - out = append(out, v) - close, err := jr.ReadArrayCloseOrComma() - if err != nil { - return nil, err - } - if close { - return out, nil - } - } -} From 51f6119025b71e56116a02b46e4bf375cd9f6bc3 Mon Sep 17 00:00:00 2001 From: ash Date: Tue, 8 Sep 2026 18:49:58 +0100 Subject: [PATCH 2/2] fix: limit iterations when reading strings from JSON Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- commands/internal/codec/codec.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commands/internal/codec/codec.go b/commands/internal/codec/codec.go index 0ed8dd6..b8f73e4 100644 --- a/commands/internal/codec/codec.go +++ b/commands/internal/codec/codec.go @@ -144,7 +144,7 @@ func ReadJSONObject(jr *jsg.DagJsonReader, fn func(name string) error) (err erro if close { return jr.ReadObjectClose() } - for { + for i := 0; i < MaxString; i++ { name, err := jr.ReadString(MaxString) if err != nil { return err @@ -163,6 +163,7 @@ func ReadJSONObject(jr *jsg.DagJsonReader, fn func(name string) error) (err erro return nil } } + return fmt.Errorf("map too large") } // ReadJSONArray reads a DAG-JSON array, invoking read for each element.