diff --git a/README.md b/README.md index 7d40ee6..1342fe8 100644 --- a/README.md +++ b/README.md @@ -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`. ```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 diff --git a/cmd/view.go b/cmd/view.go index c7a0a57..88ead8e 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -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" @@ -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 @@ -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) } @@ -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)) @@ -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") } diff --git a/cmd/view_test.go b/cmd/view_test.go new file mode 100644 index 0000000..4e64ad8 --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/fil-forge/ucantone/ucan/container" + cdm "github.com/fil-forge/ucantone/ucan/container/datamodel" + "github.com/stretchr/testify/require" +) + +const ( + testIssuer = "did:web:hilt.dev.example" + testAudience = "did:web:ingot.dev.example" +) + +// execView runs the view command and returns what it wrote to stdout. Cobra +// keeps flag values in package globals that outlive a single Execute, so they +// are reset before every run. +func execView(t *testing.T, args ...string) ([]byte, error) { + t.Helper() + + containerIndex = -1 + formatJSON = false + + var stdout, stderr bytes.Buffer + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + rootCmd.SetArgs(append([]string{"view"}, args...)) + err := rootCmd.Execute() + return stdout.Bytes(), err +} + +// writeUcanFile writes UCAN bytes to a temporary file and returns its path. +func writeUcanFile(t *testing.T, name string, data []byte) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, data, 0600)) + return path +} + +// writeProofContainer issues a delegation for each command and writes the +// resulting base64+gzip container, the shape a pasted proof arrives in. +func writeProofContainer(t *testing.T, commands ...string) string { + t.Helper() + + args := []string{"-f", writeIssuerKey(t), "-i", testIssuer, "-a", testAudience, "-s", testIssuer} + for _, cmd := range commands { + args = append(args, "-c", cmd) + } + args = append(args, "-o", "base64+gzip") + + stdout, err := execDelegate(t, args...) + require.NoError(t, err) + return writeUcanFile(t, "proof.txt", bytes.TrimRight(stdout, "\n")) +} + +// containerWithGarbage builds a raw container holding one entry that decodes as +// no UCAN token, followed by a valid delegation. +func containerWithGarbage(t *testing.T) []byte { + t.Helper() + + delegationBytes, err := execDelegate(t, "-f", writeIssuerKey(t), "-i", testIssuer, "-a", testAudience, "-s", testIssuer, "-c", "/s3/bucket/list") + require.NoError(t, err) + + model := cdm.ContainerModel{Ctn1: [][]byte{[]byte("not a ucan"), delegationBytes}} + var buf bytes.Buffer + require.NoError(t, model.MarshalCBOR(&buf)) + return append([]byte{container.Raw}, buf.Bytes()...) +} + +// containerDagJSON decodes the DAG-JSON of a container and returns its entries. +func containerDagJSON(t *testing.T, path string) []any { + t.Helper() + + stdout, err := execView(t, "-j", path) + require.NoError(t, err) + + var model map[string][]any + require.NoError(t, json.Unmarshal(stdout, &model)) + return model[containerModelKey] +} + +// entryAudience reads the audience out of one decoded entry. A token encodes as +// [signature, {tag: payload}], and the tag is matched by prefix so that the test +// does not pin the spec version. +func entryAudience(t *testing.T, entry any) string { + t.Helper() + + envelope, ok := entry.([]any) + require.True(t, ok, "entry is not a decoded token: %v", entry) + for tag, payload := range envelope[1].(map[string]any) { + if strings.HasPrefix(tag, "ucan/") { + return payload.(map[string]any)["aud"].(string) + } + } + t.Fatalf("no ucan payload in entry: %v", entry) + return "" +} + +func TestViewContainerJSON(t *testing.T) { + t.Run("holds every entry of the container", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize", "/s3/bucket/create", "/s3/bucket/list") + + require.Len(t, containerDagJSON(t, path), 3) + }) + + t.Run("decodes the entries rather than emitting their bytes", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize", "/s3/bucket/create") + + audiences := []string{} + for _, entry := range containerDagJSON(t, path) { + audiences = append(audiences, entryAudience(t, entry)) + } + require.Equal(t, []string{testAudience, testAudience}, audiences) + }) + + t.Run("counts an entry that decodes as no known token kind", func(t *testing.T) { + path := writeUcanFile(t, "broken.bin", containerWithGarbage(t)) + + require.Len(t, containerDagJSON(t, path), 2) + }) + + t.Run("writes an undecodable entry as bytes at its own index", func(t *testing.T) { + path := writeUcanFile(t, "broken.bin", containerWithGarbage(t)) + + entries := containerDagJSON(t, path) + require.Equal(t, map[string]any{"/": map[string]any{"bytes": "bm90IGEgdWNhbg"}}, entries[0]) + }) + + t.Run("leaves the entries after an undecodable one readable", func(t *testing.T) { + path := writeUcanFile(t, "broken.bin", containerWithGarbage(t)) + + entries := containerDagJSON(t, path) + require.Equal(t, testAudience, entryAudience(t, entries[1])) + }) + + t.Run("agrees with --container-index on every entry", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize", "/s3/bucket/create") + + entries := containerDagJSON(t, path) + for index := range entries { + stdout, err := execView(t, "-i", strconv.Itoa(index), "-j", path) + require.NoError(t, err) + + var entry any + require.NoError(t, json.Unmarshal(stdout, &entry)) + require.Equal(t, entry, entries[index], "entry %d", index) + } + }) + + t.Run("reads the audience without naming the spec version", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize") + + require.Equal(t, testAudience, entryAudience(t, containerDagJSON(t, path)[0])) + }) +}