diff --git a/.github/workflows/go-workspace-test.yml b/.github/workflows/go-workspace-test.yml index 35d7feb..3a12aaa 100644 --- a/.github/workflows/go-workspace-test.yml +++ b/.github/workflows/go-workspace-test.yml @@ -27,7 +27,7 @@ on: workflow_call: inputs: go-version: - description: 'Go toolchain to install. Must be >= the max `go` directive across all workspace members (guppy pins 1.26.1).' + description: 'Go toolchain to install. Must be >= the highest `go` directive among the workspace members this run clones. The default tracks the latest release, which satisfies that; override only to pin a specific toolchain.' type: string default: 'stable' diff --git a/commands/ucan/cbor_gen.go b/commands/ucan/cbor_gen.go index 8be5472..8745a04 100644 --- a/commands/ucan/cbor_gen.go +++ b/commands/ucan/cbor_gen.go @@ -32,22 +32,32 @@ func (t *ConcludeArguments) MarshalCBOR(w io.Writer) error { return err } - // t.Receipt (cid.Cid) (struct) - if len("receipt") > 8192 { - return xerrors.Errorf("Value in field \"receipt\" was too long") + // t.Receipts ([]cid.Cid) (slice) + if len("receipts") > 8192 { + return xerrors.Errorf("Value in field \"receipts\" was too long") } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("receipt"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("receipts"))); err != nil { return err } - if _, err := cw.WriteString(string("receipt")); err != nil { + if _, err := cw.WriteString(string("receipts")); err != nil { return err } - if err := cbg.WriteCid(cw, t.Receipt); err != nil { - return xerrors.Errorf("failed to write cid field t.Receipt: %w", err) + if len(t.Receipts) > 8192 { + return xerrors.Errorf("Slice value in field t.Receipts was too long") } + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Receipts))); err != nil { + return err + } + for _, v := range t.Receipts { + + if err := cbg.WriteCid(cw, v); err != nil { + return xerrors.Errorf("failed to write cid field v: %w", err) + } + + } return nil } @@ -76,7 +86,7 @@ func (t *ConcludeArguments) UnmarshalCBOR(r io.Reader) (err error) { n := extra - nameBuf := make([]byte, 7) + nameBuf := make([]byte, 8) for i := uint64(0); i < n; i++ { nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) if err != nil { @@ -92,18 +102,47 @@ func (t *ConcludeArguments) UnmarshalCBOR(r io.Reader) (err error) { } switch string(nameBuf[:nameLen]) { - // t.Receipt (cid.Cid) (struct) - case "receipt": + // t.Receipts ([]cid.Cid) (slice) + case "receipts": - { + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } - c, err := cbg.ReadCid(cr) - if err != nil { - return xerrors.Errorf("failed to read cid field t.Receipt: %w", err) - } + if extra > 8192 { + return fmt.Errorf("t.Receipts: array too large (%d)", extra) + } - t.Receipt = c + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + if extra > 0 { + t.Receipts = make([]cid.Cid, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + c, err := cbg.ReadCid(cr) + if err != nil { + return xerrors.Errorf("failed to read cid field t.Receipts[i]: %w", err) + } + + t.Receipts[i] = c + + } + + } } default: diff --git a/commands/ucan/codec_test.go b/commands/ucan/codec_test.go new file mode 100644 index 0000000..4f69cee --- /dev/null +++ b/commands/ucan/codec_test.go @@ -0,0 +1,79 @@ +//go:build !codegen + +package ucan_test + +import ( + "bytes" + "io" + "reflect" + "testing" + + ucancmds "github.com/fil-forge/libforge/commands/ucan" + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" +) + +var ( + rcptA = cid.MustParse("bafkreiaixnpf23vkyecj5xqispjq5ubcwgsntnnurw2bjaz5pmjhndcxqi") + rcptB = cid.MustParse("bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy") +) + +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() +} + +// TestConcludeArgumentsOne pins the one-receipt case: still a list, so every +// delivery has the same shape on the wire. +func TestConcludeArgumentsOne(t *testing.T) { + in := &ucancmds.ConcludeArguments{Receipts: []cid.Cid{rcptA}} + require.Equal(t, `{"receipts":[{"/":"`+rcptA.String()+`"}]}`, roundTrip(t, in)) +} + +func TestConcludeArgumentsMany(t *testing.T) { + in := &ucancmds.ConcludeArguments{Receipts: []cid.Cid{rcptA, rcptB}} + require.Equal(t, + `{"receipts":[{"/":"`+rcptA.String()+`"},{"/":"`+rcptB.String()+`"}]}`, + roundTrip(t, in)) +} + +// Delivering nothing is a no-op that encodes as an empty list. An empty slice +// decodes back as nil, which every reader treats the same way. +func TestConcludeArgumentsEmpty(t *testing.T) { + var cb bytes.Buffer + require.NoError(t, (&ucancmds.ConcludeArguments{}).MarshalCBOR(&cb)) + + var out ucancmds.ConcludeArguments + require.NoError(t, out.UnmarshalCBOR(bytes.NewReader(cb.Bytes()))) + require.Empty(t, out.Receipts) + + var jb bytes.Buffer + require.NoError(t, (&ucancmds.ConcludeArguments{}).MarshalDagJSON(&jb)) + + var outJSON ucancmds.ConcludeArguments + require.NoError(t, outJSON.UnmarshalDagJSON(bytes.NewReader(jb.Bytes()))) + require.Empty(t, outJSON.Receipts) +} diff --git a/commands/ucan/json_gen.go b/commands/ucan/json_gen.go index 07763e6..ad7e173 100644 --- a/commands/ucan/json_gen.go +++ b/commands/ucan/json_gen.go @@ -30,19 +30,37 @@ func (t *ConcludeArguments) MarshalDagJSON(w io.Writer) error { return err } - // t.Receipt (cid.Cid) (struct) - if len("receipt") > 8192 { - return fmt.Errorf("string in field \"receipt\" was too long") + // t.Receipts ([]cid.Cid) (slice) + if len("receipts") > 8192 { + return fmt.Errorf("string in field \"receipts\" was too long") } - if err := jw.WriteString(string("receipt")); err != nil { - return fmt.Errorf("writing string for field \"receipt\": %w", err) + if err := jw.WriteString(string("receipts")); err != nil { + return fmt.Errorf("writing string for field \"receipts\": %w", err) } if err := jw.WriteObjectColon(); err != nil { return err } + if len(t.Receipts) > 8192 { + return fmt.Errorf("slice value in field t.Receipts was too long") + } - if err := jw.WriteCid(t.Receipt); err != nil { - return fmt.Errorf("writing CID for field t.Receipt: %w", err) + if err := jw.WriteArrayOpen(); err != nil { + return fmt.Errorf("writing array open for field t.Receipts: %w", err) + } + for i, v := range t.Receipts { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return fmt.Errorf("writing comma for field t.Receipts: %w", err) + } + } + + if err := jw.WriteCid(v); err != nil { + return fmt.Errorf("writing CID for field v: %w", err) + } + + } + if err := jw.WriteArrayClose(); err != nil { + return fmt.Errorf("writing array close for field t.Receipts: %w", err) } if err := jw.WriteObjectClose(); err != nil { @@ -84,15 +102,49 @@ func (t *ConcludeArguments) UnmarshalDagJSON(r io.Reader) (err error) { } switch name { - // t.Receipt (cid.Cid) (struct) - case "receipt": + // t.Receipts ([]cid.Cid) (slice) + case "receipts": { - c, err := jr.ReadCid() + if err := jr.ReadArrayOpen(); err != nil { + return fmt.Errorf("reading array open for field t.Receipts: %w", err) + } + + close, err := jr.PeekArrayClose() if err != nil { - return fmt.Errorf("reading CID for field t.Receipt: %w", err) + return fmt.Errorf("peeking array close for field t.Receipts: %w", err) + } + if close { + if err := jr.ReadArrayClose(); err != nil { + return fmt.Errorf("reading array close for field t.Receipts: %w", err) + } + + } else { + for i := 0; i < 8192; i++ { + item := make([]cid.Cid, 1) + { + + c, err := jr.ReadCid() + if err != nil { + return fmt.Errorf("reading CID for field item[0]: %w", err) + } + item[0] = c + + } + t.Receipts = append(t.Receipts, item[0]) + + close, err := jr.ReadArrayCloseOrComma() + if err != nil { + return fmt.Errorf("reading array close or comma for field t.Receipts: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("reading array for field t.Receipts: slice too large") + } + } } - t.Receipt = c } default: diff --git a/commands/ucan/types.go b/commands/ucan/types.go index 9a6673c..e4b5717 100644 --- a/commands/ucan/types.go +++ b/commands/ucan/types.go @@ -2,8 +2,12 @@ package ucan import "github.com/ipfs/go-cid" +// ConcludeArguments delivers receipts to an audience awaiting them. It is +// always a list, however many are delivered: a delivering agent often holds a +// receipt per blob of a large upload, and a round trip per receipt costs more +// than the work each one triggers. An empty list delivers nothing. type ConcludeArguments struct { - Receipt cid.Cid `cborgen:"receipt" dagjsongen:"receipt"` + Receipts []cid.Cid `cborgen:"receipts" dagjsongen:"receipts"` } type RevokeArguments struct {