diff --git a/README.md b/README.md index 7d40ee6..62e54b2 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,29 @@ If you have a UCAN container, you can visualize a specific token by index: ucantool view -i 1 container.ucan ``` +##### Summary of every token + +The `--summary` flag decodes every token in the input and reports its command, audience and issuer. A single delegation is summarised the same way as a container. + +```sh +ucantool view --summary proof.txt ++---+-----------------------+---------------------------+--------------------------+ +| # | COMMAND | AUDIENCE | ISSUER | ++---+-----------------------+---------------------------+--------------------------+ +| 0 | /s3/request/authorize | did:web:ingot.dev.example | did:web:hilt.dev.example | ++---+-----------------------+---------------------------+--------------------------+ +``` + +Adding `--json` writes the summary as JSON, which also carries the subject, expiration and tag of each token. The keys are fixed by this tool rather than taken from the encoded tag, so a script keeps working when UCAN moves past the current spec version. An entry that decodes as no known token kind reports its index and an error, leaving the other entries readable. + +```sh +ucantool view --summary --json proof.txt | jq -r '.[].aud' +did:web:ingot.dev.example +``` + ##### JSON output -The `--json` flag will output `dag-json` encoding of the input. +Without `--summary`, the `--json` flag outputs the `dag-json` encoding of the input. For a container that means the container itself, with its entries as opaque bytes. ```sh ucantool view container.bin --json diff --git a/cmd/view.go b/cmd/view.go index c7a0a57..7aa3219 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -2,11 +2,14 @@ package cmd import ( "bytes" + "compress/gzip" + "encoding/base64" "errors" "fmt" "io" "os" + "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" @@ -21,6 +24,7 @@ var ( // View command flags containerIndex int formatJSON bool + summarize bool ) var viewCmd = &cobra.Command{ @@ -39,10 +43,15 @@ var viewCmd = &cobra.Command{ func init() { viewCmd.Flags().IntVarP(&containerIndex, "container-index", "i", -1, "If input is a UCAN container, view the data at this index.") viewCmd.Flags().BoolVarP(&formatJSON, "json", "j", false, "Format output as DAG-JSON.") + viewCmd.Flags().BoolVar(&summarize, "summary", false, "Report the command, issuer, audience and subject of every token in the input.") } // view reads a delegation from a file or stdin and displays its information func view(cmd *cobra.Command, args []string) error { + if summarize && containerIndex != -1 { + return errors.New("--summary cannot be combined with --container-index") + } + var ucanBytes []byte // Check if a file path is provided if len(args) >= 1 { @@ -74,17 +83,25 @@ func view(cmd *cobra.Command, args []string) error { // Try to decode! ct, 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) + // Read the entries straight out of the input rather than re-encoding the + // decoded container: re-encoding sorts the entries and drops the ones + // that decode as no known token kind, so indices and entry count would + // stop matching the file. + 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) } + // summarise every token in the container + if summarize { + return printSummary(cmd, model.Ctn1) + } + // view the container if containerIndex == -1 { link, err := cid.Prefix{ @@ -92,7 +109,7 @@ 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) } @@ -112,6 +129,10 @@ func view(cmd *cobra.Command, args []string) error { ucanBytes = model.Ctn1[containerIndex] } + if summarize { + return printSummary(cmd, [][]byte{ucanBytes}) + } + link, err := cid.V1Builder{ Codec: uint64(multicodec.DagCbor), MhType: uint64(multicodec.Sha2_256), @@ -120,25 +141,106 @@ 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 { + marshaler, ok := token.(interface{ MarshalDagJSON(io.Writer) error }) + if !ok { + return errors.New("token cannot be encoded as DAG-JSON") } - cmd.Println(ucanfmt.FormatInvocationAsTable(link, inv)) - return nil + defer cmd.Println() + return marshaler.MarshalDagJSON(cmd.OutOrStdout()) } - dlg, err := delegation.Decode(ucanBytes) - if err == nil { - if formatJSON { - defer cmd.Println() - return dlg.MarshalDagJSON(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 +} + +// printSummary decodes every entry and reports its fields. An entry that +// decodes as neither a delegation nor an invocation is reported by index with +// its error, so the remaining entries stay readable. +func printSummary(cmd *cobra.Command, entries [][]byte) error { + summary := make([]ucanfmt.SummaryEntry, 0, len(entries)) + for i, entryBytes := range entries { + token, err := decodeToken(entryBytes) + if err != nil { + summary = append(summary, ucanfmt.SummarizeError(i, err)) + continue } - cmd.Println(ucanfmt.FormatDelegationAsTable(link, dlg)) - return nil + summary = append(summary, ucanfmt.SummarizeToken(i, token)) } - return errors.New("unable to decode") + if formatJSON { + encoded, err := ucanfmt.FormatSummaryAsJSON(summary) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), encoded) + return err + } + + _, err := fmt.Fprintln(cmd.OutOrStdout(), ucanfmt.FormatSummaryAsTable(summary)) + return err +} + +// 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) + } + 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) + } + + 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. +// Both the summary and the single-index view 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..2056ce5 --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "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 + summarize = 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")) +} + +// summarize decodes the JSON summary of the given file. +func summarizeFile(t *testing.T, path string) []map[string]any { + t.Helper() + + stdout, err := execView(t, "--summary", "-j", path) + require.NoError(t, err) + + var summary []map[string]any + require.NoError(t, json.Unmarshal(stdout, &summary)) + return summary +} + +func TestViewSummary(t *testing.T) { + t.Run("reports the audience of every entry in a container", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize", "/s3/bucket/create", "/s3/bucket/list") + + audiences := map[string]string{} + for _, entry := range summarizeFile(t, path) { + audiences[entry["cmd"].(string)] = entry["aud"].(string) + } + require.Equal(t, map[string]string{ + "/s3/request/authorize": testAudience, + "/s3/bucket/create": testAudience, + "/s3/bucket/list": testAudience, + }, audiences) + }) + + t.Run("names the JSON keys without the spec version", func(t *testing.T) { + path := writeProofContainer(t, "/s3/bucket/list") + + summary := summarizeFile(t, path) + require.Len(t, summary, 1) + require.Equal(t, map[string]any{ + "index": float64(0), + "tag": "ucan/dlg@1.0.0-rc.1", + "cmd": "/s3/bucket/list", + "iss": testIssuer, + "aud": testAudience, + "sub": testIssuer, + "exp": nil, + }, summary[0]) + }) + + t.Run("summarises a delegation that is not in a container", func(t *testing.T) { + stdout, err := execDelegate(t, "-f", writeIssuerKey(t), "-i", testIssuer, "-a", testAudience, "-c", "/s3/bucket/list") + require.NoError(t, err) + path := writeUcanFile(t, "delegation.bin", stdout) + + summary := summarizeFile(t, path) + require.Equal(t, testAudience, summary[0]["aud"]) + }) + + t.Run("reports an undecodable entry by index and keeps the rest readable", func(t *testing.T) { + path := writeUcanFile(t, "broken.bin", containerWithGarbage(t)) + + summary := summarizeFile(t, path) + require.Equal(t, []map[string]any{ + {"index": float64(0), "error": "unable to decode", + "tag": nil, "cmd": nil, "iss": nil, "aud": nil, "sub": nil, "exp": nil}, + {"index": float64(1), "tag": "ucan/dlg@1.0.0-rc.1", "cmd": "/s3/bucket/list", + "iss": testIssuer, "aud": testAudience, "sub": testIssuer, "exp": nil}, + }, summary) + }) + + t.Run("agrees with --container-index on the audience of an entry", func(t *testing.T) { + path := writeProofContainer(t, "/s3/request/authorize", "/s3/bucket/create") + + summary := summarizeFile(t, path) + stdout, err := execView(t, "-i", "1", "-j", path) + require.NoError(t, err) + + // A delegation encodes as [signature, {tag: payload}]. + var envelope []any + require.NoError(t, json.Unmarshal(stdout, &envelope)) + payload := envelope[1].(map[string]any)["ucan/dlg@1.0.0-rc.1"].(map[string]any) + require.Equal(t, payload["aud"], summary[1]["aud"]) + }) + + t.Run("refuses to combine --summary with --container-index", func(t *testing.T) { + path := writeProofContainer(t, "/s3/bucket/list") + _, err := execView(t, "--summary", "-i", "0", path) + require.ErrorContains(t, err, "--summary cannot be combined with --container-index") + }) +} + +// 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()...) +} diff --git a/pkg/ucanfmt/summary.go b/pkg/ucanfmt/summary.go new file mode 100644 index 0000000..2304f67 --- /dev/null +++ b/pkg/ucanfmt/summary.go @@ -0,0 +1,109 @@ +package ucanfmt + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/fil-forge/ucantone/ucan" + ddm "github.com/fil-forge/ucantone/ucan/delegation/datamodel" + idm "github.com/fil-forge/ucantone/ucan/invocation/datamodel" +) + +// SummaryEntry is one token of a container, reduced to the fields a script +// needs. The JSON keys are fixed by this package rather than taken from the +// encoded tag, so a caller reading `.aud` keeps working when UCAN moves past +// the current spec version. Absent fields serialize as null. +type SummaryEntry struct { + Index int `json:"index"` + Tag *string `json:"tag"` + Command *string `json:"cmd"` + Issuer *string `json:"iss"` + Audience *string `json:"aud"` + Subject *string `json:"sub"` + // Expiration is unix seconds, null when the token never expires. + Expiration *int64 `json:"exp"` + // Error explains why an entry could not be decoded. Omitted otherwise. + Error string `json:"error,omitempty"` +} + +// SummarizeToken reduces a decoded delegation or invocation to a summary entry. +func SummarizeToken(index int, token ucan.Token) SummaryEntry { + entry := SummaryEntry{ + Index: index, + Tag: tagOf(token), + Command: strPtr(token.Command().String()), + Issuer: strPtr(token.Issuer().String()), + } + if token.Audience().Defined() { + entry.Audience = strPtr(token.Audience().String()) + } + if token.Subject().Defined() { + entry.Subject = strPtr(token.Subject().String()) + } + if exp := token.Expiration(); exp != nil { + seconds := int64(*exp) + entry.Expiration = &seconds + } + return entry +} + +// SummarizeError records an entry that decoded as neither a delegation nor an +// invocation, so that the remaining entries stay readable. +func SummarizeError(index int, err error) SummaryEntry { + return SummaryEntry{Index: index, Error: err.Error()} +} + +// FormatSummaryAsJSON encodes the entries as a JSON array on a single line. +func FormatSummaryAsJSON(entries []SummaryEntry) (string, error) { + if entries == nil { + entries = []SummaryEntry{} + } + encoded, err := json.Marshal(entries) + if err != nil { + return "", fmt.Errorf("marshaling summary: %w", err) + } + return string(encoded), nil +} + +// FormatSummaryAsTable renders the entries as the four columns that identify a +// token at a glance. The JSON form carries the full field set. +func FormatSummaryAsTable(entries []SummaryEntry) string { + tableString := &strings.Builder{} + table := newTable(tableString, "#", "Command", "Audience", "Issuer") + for _, entry := range entries { + command := orEmpty(entry.Command) + if entry.Error != "" { + command = fmt.Sprintf("", entry.Error) + } + table.Append([]string{ + fmt.Sprintf("%d", entry.Index), + command, + orEmpty(entry.Audience), + orEmpty(entry.Issuer), + }) + } + renderTable(table) + return tableString.String() +} + +func tagOf(token ucan.Token) *string { + switch token.(type) { + case ucan.Invocation: + return strPtr(idm.Tag) + case ucan.Delegation: + return strPtr(ddm.Tag) + } + return nil +} + +func strPtr(s string) *string { + return &s +} + +func orEmpty(s *string) string { + if s == nil { + return "" + } + return *s +}