Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,40 @@ ucantool view -i 1 container.ucan

##### JSON output

The `--json` flag will output `dag-json` encoding of the input.
The `--json` flag outputs the `dag-json` encoding of the input. For a container, the entries are decoded in place and appear in the order they have in the input, so an array index selects the same token as `--container-index`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked Claude to explain this breaking change:

The | jq in the README is cosmetic — the tool prints one line either way, jq just pretty-prints it. Not a change.

The entry shape is a breaking change. .["ctn-v1"][N] was {"/":{"bytes":"…"}} and is now [sig, {tag: payload}]. Verified consequence:

old output → container.UnmarshalDagJSON:
new output → container.UnmarshalDagJSON: expected object open but read [

There's a second problem underneath it. --json is documented as "the dag-json encoding of the input", and for a container the ctn-v1 field genuinely is a list of byte strings. So the new output is valid DAG-JSON but it is not the DAG-JSON encoding of the container. My change quietly redefines what the flag means, and the mixed-shape array (token envelope or raw bytes) is a wart that only exists because of that redefinition.

Worth noting the entry-source fix is separable and not breaking on its own: reading entries from the input instead of the roundtrip fixes the order, the count and the -i disagreement while keeping the byte-string shape.


```sh
ucantool view container.bin --json
{"ctn-v1":[{"/":{"bytes":"glhAR66mRiQ8FKsCM4aoM9sdLs+HYkG6GTTyqGl0XAE9nr9PGgFtg2gLimfiYFjoD90bBEeqG6P6AMWnUwvolA0MD6JhaEg0Ae0B7QETcXN1Y2FuL2RsZ0AxLjAuMC1yYy4xp2NhdWR4OGRpZDprZXk6ejZNa3M3UHhxVGVCNmhWQWllYWZoRGtlYVVKYWpEQTVyQ01qWHYxUVEyc1NxbWo1Y2NtZHAvZnJ1aXRzL3B1cmNoYXNlY2V4cBppHF6WY2lzc3RkaWQ6d2ViOmZydWl0Lm1hcmtldGNwb2yBg2NhbGxnLmZydWl0c4Jib3KDg2I9PWEuZWFwcGxlg2I9PWEuZm9yYW5nZYNiPT1hLmZiYW5hbmFjc3VidGRpZDp3ZWI6ZnJ1aXQubWFya2V0ZW5vbmNlUKn5t5tUI9ePips/9FYLOww"}},{"/":{"bytes":"glhAckRmUKVOqWffQV+++DJMLSqHTk/wCDqWsMXZpajZ67hX1HMsmNz8OEqaALpzvnaQWqbtoM3JjQ7zTlO8gKLED6JhaEg0Ae0B7QETcXN1Y2FuL2ludkAxLjAuMC1yYy4xqWNhdWR0ZGlkOndlYjpmcnVpdC5tYXJrZXRjY21kdC91Y2FuL2Fzc2VydC9yZWNlaXB0Y2V4cBppHF6WY2lhdBppHF54Y2lzc3RkaWQ6d2ViOmZydWl0Lm1hcmtldGNwcmaAY3N1YnRkaWQ6d2ViOmZydWl0Lm1hcmtldGRhcmdzomNvdXShYm9rGCpjcmFu2CpYJQABcRIgewTVERdle8QnvMiXLq+K8NY5RZEBnvxy8WNXv23scT9lbm9uY2VQjaUQqg4PnK2wOT4VxFw03w"}},{"/":{"bytes":"glhA2uUTIRx6xLliKr+3EUhFgBFpnBP0Zeew9yZ6ma733xiF7vLS1krqa6yZimBxun8DjMlsYHeu18b+NuBvkMlwCaJhaEg0Ae0B7QETcXN1Y2FuL2ludkAxLjAuMC1yYy4xqWNjbWRwL2ZydWl0cy9wdXJjaGFzZWNleHAaaRxelmNpYXQaaRxeeGNpc3N4OGRpZDprZXk6ejZNa3M3UHhxVGVCNmhWQWllYWZoRGtlYVVKYWpEQTVyQ01qWHYxUVEyc1NxbWo1Y3ByZoHYKlglAAFxEiBBbvyIkSr+mDAubWKbg5WKadYbY+ZoN0lRhyyxHf18hWNzdWJ0ZGlkOndlYjpmcnVpdC5tYXJrZXRkYXJnc6FmZnJ1aXRzgmVhcHBsZWZiYW5hbmFkbWV0YaViaWR4OGRpZDprZXk6ejZNa2d5NWUyTHRwcUFTcWZ6MUtUNkc1ZHFiaTV4WVE0V1A0a2kxaXY0WHRuaFlHZGJsb2KhZmRpZ2VzdEMBAgNkbmFtZWR0ZXN0ZHJvb3TYKlglAAFVEiDH0BSJCAhYxQAGWDbGWPhHpspnxIZGGSEr5PggDku6zmRzaXplGQPoZW5vbmNlUC/rE9w/ky0qf8Ha+FwAQPs"}}]}
ucantool view container.ucan --json | jq
{
"ctn-v1": [
[
{ "/": { "bytes": "oqERLgIA2ARG2edPh5/B3vl7AVoTGgHsOW35rsati3wnyeSltaryvuqV4N3iGZ/3VXltF4+iaKSBgLH/avNxDg" } },
{
"h": { "/": { "bytes": "NAHtAe0BE3E" } },
"ucan/dlg@1.0.0-rc.1": {
"aud": "did:web:ingot.dev.example",
"cmd": "/s3/request/authorize",
"exp": null,
"iss": "did:web:hilt.dev.example",
"nonce": { "/": { "bytes": "Su8vYJv5oLeh+xeYd3nqzw" } },
"pol": [],
"sub": "did:web:hilt.dev.example"
}
}
]
]
}
```

Counting the tokens in a container therefore takes one command:

```sh
ucantool view container.ucan --json | jq '.["ctn-v1"] | length'
1
```

An entry that decodes as no known token kind is written as its bytes, `{"/":{"bytes":"…"}}`, keeping the entry count and every index aligned with the input. The output is a view of the container rather than a re-encoding of it: feeding it back in as `dag-json` is not supported.

## Use as a library

Generating delegations does not require the CLI. `pkg/ucandelegate` issues them
Expand Down
164 changes: 141 additions & 23 deletions cmd/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package cmd

import (
"bytes"
"compress/gzip"
"encoding/base64"
"errors"
"fmt"
"io"
"os"

jsg "github.com/alanshaw/dag-json-gen"
"github.com/fil-forge/ucantone/ucan"
"github.com/fil-forge/ucantone/ucan/container"
cdm "github.com/fil-forge/ucantone/ucan/container/datamodel"
"github.com/fil-forge/ucantone/ucan/delegation"
Expand All @@ -17,6 +21,10 @@ import (
"github.com/spf13/cobra"
)

// containerModelKey is the single field of the container datamodel, matching the
// `dagjsongen` tag ucantone puts on ContainerModel.Ctn1.
const containerModelKey = "ctn-v1"

var (
// View command flags
containerIndex int
Expand Down Expand Up @@ -71,17 +79,21 @@ func view(cmd *cobra.Command, args []string) error {
ucanBytes = stdinBytes
}

// Try to decode!
ct, err := container.Decode(ucanBytes)
// Try to decode! The decoded container is discarded: it says whether the input
// is a container, and the entries are then read out of the input itself.
_, err := container.Decode(ucanBytes)
if err == nil {
// encode using raw codec so we can take the hash of the CBOR data
rawContainerBytes, err := container.Encode(container.Raw, ct)
// Reading the entries from the input rather than from a re-encode of the
// decoded container: re-encoding sorts the entries bytewise and drops the
// ones that decode as no known token kind, so both the entry count and the
// index -i takes would stop matching the input.
containerBytes, err := decodeContainerCBOR(ucanBytes)
if err != nil {
return fmt.Errorf("encoding raw container bytes: %w", err)
return fmt.Errorf("decoding container bytes: %w", err)
}

model := cdm.ContainerModel{}
if err := model.UnmarshalCBOR(bytes.NewReader(rawContainerBytes[1:])); err != nil {
if err := model.UnmarshalCBOR(bytes.NewReader(containerBytes)); err != nil {
return fmt.Errorf("decoding container model: %w", err)
}

Expand All @@ -92,13 +104,13 @@ func view(cmd *cobra.Command, args []string) error {
Codec: uint64(multicodec.DagCbor),
MhType: uint64(multicodec.Sha2_256),
MhLength: -1,
}.Sum(rawContainerBytes[1:])
}.Sum(containerBytes)
if err != nil {
return fmt.Errorf("hashing data: %w", err)
}
if formatJSON {
defer cmd.Println()
return ct.MarshalDagJSON(cmd.OutOrStdout())
return printContainerDagJSON(cmd, model.Ctn1)
}

cmd.Println(ucanfmt.FormatContainerAsTable(link, ucanBytes[0], &model))
Expand All @@ -120,25 +132,131 @@ func view(cmd *cobra.Command, args []string) error {
return fmt.Errorf("hashing data: %w", err)
}

inv, err := invocation.Decode(ucanBytes)
if err == nil {
if formatJSON {
defer cmd.Println()
return inv.MarshalDagJSON(cmd.OutOrStdout())
token, err := decodeToken(ucanBytes)
if err != nil {
return err
}

if formatJSON {
defer cmd.Println()
return marshalTokenDagJSON(token, cmd.OutOrStdout())
}

switch tok := token.(type) {
case ucan.Invocation:
cmd.Println(ucanfmt.FormatInvocationAsTable(link, tok))
case ucan.Delegation:
cmd.Println(ucanfmt.FormatDelegationAsTable(link, tok))
}
return nil
}

// printContainerDagJSON writes the container's entries as DAG-JSON, decoded, in
// the order they appear in the input. An entry that decodes as no known token
// kind is written as its bytes, so every entry of the input is present and an
// array index still selects what -i selects.
func printContainerDagJSON(cmd *cobra.Command, entries [][]byte) error {
jw := jsg.NewDagJsonWriter(cmd.OutOrStdout())
if err := jw.WriteObjectOpen(); err != nil {
return err
}
if err := jw.WriteString(containerModelKey); err != nil {
return err
}
if err := jw.WriteObjectColon(); err != nil {
return err
}
if err := jw.WriteArrayOpen(); err != nil {
return err
}
for i, entryBytes := range entries {
if i > 0 {
if err := jw.WriteComma(); err != nil {
return err
}
}
cmd.Println(ucanfmt.FormatInvocationAsTable(link, inv))
return nil
if err := writeEntryDagJSON(jw, entryBytes); err != nil {
return fmt.Errorf("encoding entry %d: %w", i, err)
}
}
if err := jw.WriteArrayClose(); err != nil {
return err
}
return jw.WriteObjectClose()
}

dlg, err := delegation.Decode(ucanBytes)
if err == nil {
if formatJSON {
defer cmd.Println()
return dlg.MarshalDagJSON(cmd.OutOrStdout())
// writeEntryDagJSON writes one entry as the DAG-JSON of its token, falling back
// to the entry's bytes when it decodes as no known token kind.
func writeEntryDagJSON(jw *jsg.DagJsonWriter, entryBytes []byte) error {
token, err := decodeToken(entryBytes)
if err != nil {
return jw.WriteBytes(entryBytes)
}
if err := marshalTokenDagJSON(token, jw); err != nil {
return jw.WriteBytes(entryBytes)
}
return nil
}

// marshalTokenDagJSON writes a decoded token as DAG-JSON.
func marshalTokenDagJSON(token ucan.Token, w io.Writer) error {
marshaler, ok := token.(interface{ MarshalDagJSON(io.Writer) error })
if !ok {
return errors.New("token cannot be encoded as DAG-JSON")
}
return marshaler.MarshalDagJSON(w)
}

// decodeContainerCBOR strips the container transport encoding and returns the
// CBOR of the container model. It mirrors the codec handling of
// container.Decode, which returns decoded tokens rather than the raw entries.
func decodeContainerCBOR(input []byte) ([]byte, error) {
if len(input) == 0 {
return nil, errors.New("empty container bytes")
}

codec := input[0]
var payload []byte
switch codec {
case container.Raw, container.RawGzip:
payload = input[1:]
case container.Base64, container.Base64Gzip:
decoded, err := base64.StdEncoding.DecodeString(string(input[1:]))
if err != nil {
return nil, fmt.Errorf("decoding base64: %w", err)
}
cmd.Println(ucanfmt.FormatDelegationAsTable(link, dlg))
return nil
payload = decoded
case container.Base64url, container.Base64urlGzip:
decoded, err := base64.RawURLEncoding.DecodeString(string(input[1:]))
if err != nil {
return nil, fmt.Errorf("decoding base64url: %w", err)
}
payload = decoded
default:
return nil, fmt.Errorf("unknown codec: 0x%02x", codec)
}

return errors.New("unable to decode")
switch codec {
case container.RawGzip, container.Base64Gzip, container.Base64urlGzip:
gz, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("creating gzip reader: %w", err)
}
defer gz.Close()
return io.ReadAll(gz)
}
return payload, nil
}

// decodeToken decodes UCAN bytes as whichever token kind they turn out to be.
// The container entries and a lone token go through here, so they cannot
// disagree about what an entry is.
func decodeToken(ucanBytes []byte) (ucan.Token, error) {
if inv, err := invocation.Decode(ucanBytes); err == nil {
return inv, nil
}
if dlg, err := delegation.Decode(ucanBytes); err == nil {
return dlg, nil
}
return nil, errors.New("unable to decode")
}
Loading