diff --git a/README.md b/README.md index 3afaa3f..ab080cd 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,12 @@ Grouped separately from the contract data commands above. All of these call the | `etherscan gastracker oracle` | Get safe, proposed, and fast gas prices | [gasoracle](https://docs.etherscan.io/api-reference/endpoint/gasoracle.md) | | `etherscan gastracker estimate` | Estimate confirmation time for a gas price | [gasestimate](https://docs.etherscan.io/api-reference/endpoint/gasestimate.md) | +### ENS + +| Command | Description | API docs | +| --- | --- | --- | +| `etherscan ens forwardresolve` | Resolve an ENS name to its address (Ethereum mainnet) | [forwardresolve](https://docs.etherscan.io/api-reference/endpoint/forwardresolve.md) | + ### Nametag | Command | Description | API docs | diff --git a/cmd/etherscan/main.go b/cmd/etherscan/main.go index 0809fa5..a77c7c4 100644 --- a/cmd/etherscan/main.go +++ b/cmd/etherscan/main.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/etherscan/etherscan-cli/internal/cli" + "github.com/etherscan/etherscan-cli/internal/output" ) // These are overridden at release time via -ldflags (see .goreleaser.yaml). A @@ -26,7 +27,7 @@ func main() { root := cli.NewRootCommand(buildInfo()) if err := root.ExecuteContext(ctx); err != nil { - fmt.Fprintln(os.Stderr, "error:", err) + fmt.Fprintln(os.Stderr, "error:", output.Sanitize(err.Error())) os.Exit(1) } } diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 5080578..0ee1a40 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -89,6 +89,7 @@ var groupMetaOverride = map[string]groupMeta{ hint: "contract verification commands moved to \"etherscan contractverification\"", }, "contractverification": {short: "Etherscan contract verification commands"}, + "ens": {short: "Etherscan ENS name resolution commands"}, } // groupShort returns the parent command's Short description for a CLI group. @@ -160,6 +161,9 @@ func endpoints() []EndpointSpec { {Module: "token", Action: "tokenholdercount", Use: "tokenholdercount ", Short: "Get token holder count", Params: []ParamSpec{arg("contractaddress", "token contract", KindAddress)}}, {Module: "token", Action: "topholders", Use: "topholders ", Short: "Get top holders", Params: []ParamSpec{arg("contractaddress", "token contract", KindAddress), p("offset", "limit", KindUint)}}, } + ens := []EndpointSpec{ + {Module: "ens", Action: "forwardresolve", Use: "forwardresolve ", Short: "Resolve an ENS name to an address", Params: []ParamSpec{arg("name", "ENS name, e.g. etherscan.eth", KindString)}, MainnetOnly: true}, + } nametag := []EndpointSpec{ {Module: "nametag", Action: "getaddresstag", Use: "getaddresstag ", Short: "Get address name tags and metadata (Pro Plus)", Params: []ParamSpec{argAddresses("address", 100)}, Columns: []string{"address", "nametag", "labels", "reputation"}}, } @@ -172,6 +176,7 @@ func endpoints() []EndpointSpec { out = append(out, stats...) out = append(out, token...) out = append(out, gas...) + out = append(out, ens...) out = append(out, nametag...) out = append(out, proxy...) out = append(out, EndpointSpec{Module: "getapilimit", RootLevel: true, Action: "getapilimit", Use: "apilimit", Short: "Show API credit usage", Columns: []string{"creditsUsed", "creditsAvailable", "creditLimit", "limitInterval", "intervalExpiryTimespan"}}) diff --git a/internal/cli/registry_test.go b/internal/cli/registry_test.go index e2e5dfc..43a2807 100644 --- a/internal/cli/registry_test.go +++ b/internal/cli/registry_test.go @@ -29,7 +29,7 @@ func TestEndpointRegistryCoversPromptModules(t *testing.T) { for _, spec := range endpoints() { got[spec.Module]++ } - for _, module := range []string{"account", "contract", "transaction", "block", "proxy", "logs", "stats", "token", "gastracker", "nametag", "getapilimit"} { + for _, module := range []string{"account", "contract", "transaction", "block", "proxy", "logs", "stats", "token", "gastracker", "ens", "nametag", "getapilimit"} { if got[module] == 0 { t.Fatalf("missing module %s", module) } diff --git a/internal/cli/root.go b/internal/cli/root.go index 67cfa73..6466bb0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -199,7 +199,7 @@ func endpointCommand(state *globalState, spec EndpointSpec) *cobra.Command { } } if state.all && spec.Paginated { - return runAllPages(cmd.Context(), rt, spec, params, state.maxPages) + return runAllPages(cmd.Context(), rt, spec, params, state.maxPages, state.compact) } if err := validatePagination(spec, params); err != nil { return err @@ -381,13 +381,16 @@ func call(ctx context.Context, c *client.Client, spec EndpointSpec, params map[s return c.Get(ctx, spec.Module, spec.Action, clean, !spec.NoRetry) } -func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, params map[string]string, maxPages int) error { +func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, params map[string]string, maxPages int, compact bool) error { offset := params["offset"] if offset == "" { offset = "100" params["offset"] = offset } - var combined []map[string]string + // Rows stay raw JSON so the combined output matches a single-page call. Converting + // them to display rows here would turn arrays (getLogs topics), numbers, booleans + // and nulls into strings; output.Write does that conversion for table and CSV only. + var combined []json.RawMessage reachedEnd := false limit := max(1, maxPages) for page := 1; page <= limit; page++ { @@ -396,12 +399,10 @@ func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, par if err != nil { return err } - rows, scalar, err := output.Rows(result.Raw) - if err != nil { - return err - } - if scalar != "" { - return output.Write(os.Stdout, result.Raw, rt.format, false, spec.Columns) + var rows []json.RawMessage + if err := json.Unmarshal(result.Raw, &rows); err != nil { + // Not a list (e.g. a scalar message): print it as a single call would. + return output.Write(os.Stdout, result.Raw, rt.format, compact, spec.Columns) } combined = append(combined, rows...) fmt.Fprintf(os.Stderr, "fetched page %d (%d rows)\n", page, len(rows)) @@ -417,7 +418,7 @@ func runAllPages(ctx context.Context, rt resolvedRuntime, spec EndpointSpec, par if err != nil { return err } - return output.Write(os.Stdout, raw, rt.format, false, spec.Columns) + return output.Write(os.Stdout, raw, rt.format, compact, spec.Columns) } // validateKeyLive checks a key against the API (getapilimit) before it is saved, @@ -1011,7 +1012,7 @@ func tuiExec(rt *resolvedRuntime, index map[string]EndpointSpec) tui.Exec { // cross-reference the docs; the CLI command tree is unaffected. var tuiModuleOrder = []string{ "account", "block", "contract", "verification", "gastracker", "proxy", - "logs", "stats", "transaction", "token", "nametag", "usage", + "logs", "stats", "transaction", "token", "ens", "nametag", "usage", } // tuiGroupLabel maps a CLI command group to its sidebar label where the two differ. @@ -1081,6 +1082,7 @@ var tuiActionOrder = map[string][]string{ }, "transaction": {"getstatus", "gettxreceiptstatus"}, "token": {"topholders", "tokenholderlist", "tokenholdercount", "tokeninfo"}, + "ens": {"forwardresolve"}, "usage": {"getapilimit", "chainlist"}, } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b4807b7..43c0647 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -242,3 +244,154 @@ func TestChainsDoesNotFallBackOnOutage(t *testing.T) { t.Fatalf("chains printed a stale list instead of failing:\n%s", got) } } + +// ens/forwardresolve: the CLI sends the name to the mainnet ens module, surfaces +// the server's resolution error verbatim, and refuses other chains before any +// request is made (the server only serves this action on chainid 1). +func TestEnsForwardResolve(t *testing.T) { + const address = "0xcefcc00a025d6bcc259d082c883144c36c17903f" + for _, tc := range []struct { + name string + chain string + reply string + wantRequests int + wantErr string + }{ + {name: "resolves", chain: "1", reply: `{"status":"1","message":"OK","result":"` + address + `"}`, wantRequests: 1}, + {name: "unresolved", chain: "1", reply: `{"status":"0","message":"NOTOK","result":"Error! Unable to resolve ENS name"}`, wantRequests: 1, wantErr: "Error! Unable to resolve ENS name"}, + {name: "non-mainnet", chain: "8453", wantRequests: 0, wantErr: "only supported on Ethereum mainnet"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + q := r.URL.Query() + for key, want := range map[string]string{"module": "ens", "action": "forwardresolve", "name": "etherscan.eth", "chainid": "1"} { + if got := q.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + _, _ = w.Write([]byte(tc.reply)) + })) + defer server.Close() + + capture := filepath.Join(t.TempDir(), "stdout") + file, err := os.Create(capture) + if err != nil { + t.Fatal(err) + } + originalStdout := os.Stdout + os.Stdout = file + root := newRootCommand(BuildInfo{}, &fakeUpdateManager{}) + root.SetArgs([]string{"--api-key", "TESTKEY", "--base-url", server.URL + "/v2/api", "--chain", tc.chain, "ens", "forwardresolve", "etherscan.eth"}) + execErr := root.Execute() + os.Stdout = originalStdout + file.Close() + + if requests != tc.wantRequests { + t.Fatalf("requests = %d, want %d", requests, tc.wantRequests) + } + if tc.wantErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tc.wantErr) { + t.Fatalf("error = %v, want %q", execErr, tc.wantErr) + } + return + } + if execErr != nil { + t.Fatal(execErr) + } + got, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), address) { + t.Fatalf("output missing resolved address; got:\n%s", got) + } + }) + } +} + +// --all must keep each row's JSON types. It used to flatten rows to strings, so +// getLogs topics came out as a JSON-encoded string instead of an array, and +// numbers, booleans and nulls were stringified too. +func TestAllPagesPreservesJSONTypes(t *testing.T) { + const row = `{"address":"0xabc","topics":["0xa","0xb"],"logIndex":7,"removed":false,"data":null}` + for _, compact := range []bool{false, true} { + t.Run(fmt.Sprintf("compact=%v", compact), func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + result := "[" + row + "," + row + "]" // a full page (offset=2) + if r.URL.Query().Get("page") != "1" { + result = "[" + row + "]" // a short page ends pagination + } + fmt.Fprintf(w, `{"status":"1","message":"OK","result":%s}`, result) + })) + defer server.Close() + + args := []string{"--api-key", "TESTKEY", "--base-url", server.URL + "/v2/api", "--rate-limit", "1000", + "logs", "get", "--address", "0x0000000000000000000000000000000000000abc", "--offset", "2", "--all"} + if compact { + args = append(args, "--compact") + } + out := captureStdout(t, func() { + root := newRootCommand(BuildInfo{}, &fakeUpdateManager{}) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + if requests != 2 { + t.Fatalf("requests = %d, want 2 pages", requests) + } + if got := strings.Count(strings.TrimSpace(out), "\n"); compact && got != 0 { + t.Fatalf("--compact --all printed %d newlines, want one line:\n%s", got, out) + } + var rows []map[string]any + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("output is not a JSON array: %v\n%s", err, out) + } + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3", len(rows)) + } + for i, r := range rows { + if topics, ok := r["topics"].([]any); !ok || len(topics) != 2 { + t.Fatalf("row %d topics = %#v, want a 2-element array", i, r["topics"]) + } + if r["logIndex"] != float64(7) || r["removed"] != false { + t.Fatalf("row %d lost types: logIndex=%#v removed=%#v", i, r["logIndex"], r["removed"]) + } + if data, ok := r["data"]; !ok || data != nil { + t.Fatalf("row %d data = %#v, want null", i, data) + } + } + }) + } +} + +// captureStdout runs fn with os.Stdout (and os.Stderr, for progress lines) +// redirected to a temp file and returns what was written to stdout. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + f, err := os.Create(filepath.Join(t.TempDir(), "stdout")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer devNull.Close() + originalStdout, originalStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = f, devNull + defer func() { os.Stdout, os.Stderr = originalStdout, originalStderr }() + fn() + out, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + return string(out) +} diff --git a/internal/config/config.go b/internal/config/config.go index 67582c9..26153da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,12 +58,111 @@ func Save(cfg File) (string, error) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return "", err } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + return path, writeFileAtomic(path, cfg) +} + +// writeFileAtomic encodes cfg to a private temp file beside the target and renames +// it into place. The config holds the API key, so the result is always 0600 even +// when an older file was more permissive (OpenFile's mode only applies on create), +// and a failed write leaves the previous config intact instead of truncated. A +// symlinked config (e.g. from a dotfiles repo) is updated at its target, so the +// link survives. +func writeFileAtomic(path string, cfg File) (err error) { + path, err = resolveLink(path) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.toml") + if err != nil { + return err + } + defer func() { + if err != nil { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + if err = tmp.Chmod(0o600); err != nil { + return err + } + if err = toml.NewEncoder(tmp).Encode(cfg); err != nil { + return err + } + if err = tmp.Sync(); err != nil { + return err + } + if err = tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// resolveLink follows a symlink chain to the file it names, even when that file does +// not exist yet. filepath.EvalSymlinks fails on a dangling link, and renaming over the +// link itself would replace it with a regular file; a dotfiles setup that keeps the +// API-key file out of the repo leaves exactly such a link on a fresh machine. +// +// Relative targets are appended to the link's directory as raw text, never via +// filepath.Join or filepath.Dir: those clean ".." lexically, but when the directory +// before it is itself a symlink the filesystem resolves ".." from the link's target. +// A path that is not a symlink is returned untouched, so plain configs never reach +// EvalSymlinks (which can fail on some Windows network and subst drives). +func resolveLink(path string) (string, error) { + linked := false + for range 40 { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) || (err == nil && info.Mode()&os.ModeSymlink == 0) { + if !linked { + return path, nil + } + return physicalPath(path) + } + if err != nil { + return "", err + } + target, err := os.Readlink(path) + if err != nil { + return "", err + } + linked = true + switch { + case filepath.IsAbs(target): + path = target + case target != "" && os.IsPathSeparator(target[0]): // rooted without a drive (Windows) + path = filepath.VolumeName(path) + target + default: + dir, _ := splitRaw(path) + path = dir + target + } + } + return "", fmt.Errorf("%s: too many levels of symbolic links", path) +} + +// physicalPath turns a path reached through symlinks into a clean one naming the same +// file, so the temp file is created in the directory the rename will land in. +// EvalSymlinks walks the directory part as the filesystem does, ".." included. +func physicalPath(path string) (string, error) { + dir, base := splitRaw(path) + if base == "" || base == "." || base == ".." { + return "", fmt.Errorf("%s: config path does not name a file", path) + } + if dir == "" { + dir = "." + } + realDir, err := filepath.EvalSymlinks(dir) if err != nil { return "", err } - defer f.Close() - return path, toml.NewEncoder(f).Encode(cfg) + return filepath.Join(realDir, base), nil +} + +// splitRaw splits path after its last separator without cleaning either half. +func splitRaw(path string) (dir, base string) { + i := len(path) - 1 + for i >= len(filepath.VolumeName(path)) && !os.IsPathSeparator(path[i]) { + i-- + } + return path[:i+1], path[i+1:] } func GetAPIKey(fallback File) (string, string) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 04bf74b..e16455f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,12 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) func TestDeleteAPIKeyClearsPlaintext(t *testing.T) { cfg := File{APIKey: "PLAINTEXTKEY"} @@ -42,3 +48,236 @@ func TestSetDefaultChainRequiresNumericID(t *testing.T) { } } } + +// Save replaces the file atomically: the result is always 0600 (OpenFile's mode +// only applied on create, so a 0644 file kept its mode with the key in it), and no +// temp files are left behind. +func TestSaveTightensExistingPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file modes") + } + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path, err := DefaultPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("default_chain = \"1\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { // defeat umask + t.Fatal(err) + } + if _, err := Save(File{DefaultChain: "1", APIKey: "SECRETKEY"}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("config mode = %o, want 600", mode) + } + assertOnlyConfigInDir(t, path) +} + +// A save that fails at the final step reports the error and leaves no temp file. +// The previous config is only ever replaced by that rename, so it stays intact; +// before, O_TRUNC emptied it before encoding started. +func TestFailedSaveCleansUpTempFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path, _ := DefaultPath() + // Rename cannot replace a non-empty directory, so the temp file is written and + // then the rename fails. + if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o700); err != nil { + t.Fatal(err) + } + if _, err := Save(File{DefaultChain: "1", APIKey: "NEWKEY"}); err == nil { + t.Fatal("expected Save to fail when the config path is a directory") + } + if _, err := os.Stat(filepath.Join(path, "blocker")); err != nil { + t.Fatalf("existing config path was disturbed: %v", err) + } + assertOnlyConfigInDir(t, path) +} + +// A config symlinked from elsewhere (e.g. a dotfiles repo) is updated at its +// target; the link itself must survive the rename. +func TestSaveWritesThroughSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path, _ := DefaultPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "dotfiles-config.toml") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := Save(File{DefaultChain: "1", APIKey: "LINKEDKEY"}); err != nil { + t.Fatal(err) + } + if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("config symlink was replaced: %v", err) + } + data, err := os.ReadFile(target) + if err != nil || !strings.Contains(string(data), "LINKEDKEY") { + t.Fatalf("symlink target not updated: %q, %v", data, err) + } +} + +// A dotfiles setup that keeps the API-key file out of the repo leaves a dangling link +// on a fresh machine; saving must create the target rather than replace the link. +func TestSaveWritesThroughDanglingSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path, _ := DefaultPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "dotfiles-config.toml") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + assertSavedThroughLink(t, path, target) +} + +func TestSaveWritesThroughDanglingRelativeSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + path, _ := DefaultPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + dotfiles := filepath.Join(xdg, "dotfiles") + if err := os.MkdirAll(dotfiles, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "dotfiles", "config.toml"), path); err != nil { + t.Fatal(err) + } + assertSavedThroughLink(t, path, filepath.Join(dotfiles, "config.toml")) +} + +// ".." in a relative link target must be resolved the way the filesystem does: after +// following any symlinked directory before it, not by trimming the path as text. +func TestSaveResolvesRelativeLinksPhysically(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + cases := []struct { + name string + setup func(t *testing.T, tmp, configs string) (link string) + want string // relative to tmp + wrong string // where lexical cleaning would have written + }{ + { + name: "config dir is a symlink", + setup: func(t *testing.T, tmp, configs string) string { + return filepath.Join("..", "target.toml") + }, + want: filepath.Join("real", "target.toml"), + wrong: filepath.Join("xdg", "target.toml"), + }, + { + name: "symlinked dir then .. inside the target", + setup: func(t *testing.T, tmp, configs string) string { + deep := filepath.Join(tmp, "elsewhere", "deep", "dir") + if err := os.MkdirAll(deep, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(deep, filepath.Join(configs, "sub")); err != nil { + t.Fatal(err) + } + return "sub/../target.toml" // not filepath.Join: it would clean away the ".." + }, + want: filepath.Join("elsewhere", "deep", "target.toml"), + wrong: filepath.Join("real", "configs", "target.toml"), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmp := t.TempDir() + configs := filepath.Join(tmp, "real", "configs") + if err := os.MkdirAll(configs, 0o700); err != nil { + t.Fatal(err) + } + xdg := filepath.Join(tmp, "xdg") + if err := os.MkdirAll(xdg, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", xdg) + path, _ := DefaultPath() // /etherscan/config.toml + if err := os.Symlink(configs, filepath.Dir(path)); err != nil { + t.Fatal(err) + } + if err := os.Symlink(tc.setup(t, tmp, configs), path); err != nil { + t.Fatal(err) + } + assertSavedThroughLink(t, path, filepath.Join(tmp, tc.want)) + if _, err := os.Lstat(filepath.Join(tmp, tc.wrong)); !os.IsNotExist(err) { + t.Fatalf("config was written to the lexically cleaned path %s", tc.wrong) + } + }) + } +} + +func assertSavedThroughLink(t *testing.T, path, target string) { + t.Helper() + if _, err := Save(File{DefaultChain: "1", APIKey: "LINKEDKEY"}); err != nil { + t.Fatal(err) + } + if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("config symlink was replaced: %v", err) + } + data, err := os.ReadFile(target) + if err != nil || !strings.Contains(string(data), "LINKEDKEY") { + t.Fatalf("symlink target not created: %q, %v", data, err) + } + if info, err := os.Stat(target); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("target should be 0600: %v, %v", info, err) + } +} + +func TestSaveRoundTrips(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + want := File{APIKey: "ROUNDTRIP", DefaultChain: "8453", DefaultOutput: "table"} + for i := 0; i < 2; i++ { // second save replaces an existing file + if _, err := Save(want); err != nil { + t.Fatal(err) + } + } + got, path, err := Load() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("Load = %+v, want %+v", got, want) + } + assertOnlyConfigInDir(t, path) +} + +func assertOnlyConfigInDir(t *testing.T, path string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Name() != filepath.Base(path) { + t.Errorf("leftover file in config dir: %s", e.Name()) + } + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 0ea88ca..2e6fe18 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + "unicode" + "unicode/utf8" "github.com/olekukonko/tablewriter" ) @@ -50,8 +52,12 @@ func Write(w io.Writer, raw json.RawMessage, format Format, compact bool, column } return err } + // UseNumber keeps numeric literals exact: decoding into float64 would round + // integers above 2^53 and rewrite decimals such as 1.50 as 1.5. var v any - if err := json.Unmarshal(raw, &v); err != nil { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { return err } enc := json.NewEncoder(w) @@ -64,7 +70,7 @@ func Write(w io.Writer, raw json.RawMessage, format Format, compact bool, column return err } if scalar != "" { - _, err := fmt.Fprintln(w, scalar) + _, err := fmt.Fprintln(w, Sanitize(scalar)) return err } if format == CSV { @@ -120,8 +126,12 @@ func writeTable(w io.Writer, rows []map[string]string, preferred []string) error return nil } cols := columns(rows, preferred) + header := make([]string, len(cols)) + for i, col := range cols { + header[i] = Sanitize(col) + } t := tablewriter.NewWriter(w) - t.SetHeader(cols) + t.SetHeader(header) t.SetAutoWrapText(false) t.SetAlignment(tablewriter.ALIGN_LEFT) t.SetHeaderAlignment(tablewriter.ALIGN_LEFT) @@ -149,13 +159,17 @@ func writeCSV(w io.Writer, rows []map[string]string, preferred []string) error { } cols := columns(rows, preferred) cw := csv.NewWriter(w) - if err := cw.Write(cols); err != nil { + header := make([]string, len(cols)) + for i, col := range cols { + header[i] = Sanitize(col) + } + if err := cw.Write(header); err != nil { return err } for _, row := range rows { record := make([]string, len(cols)) for i, col := range cols { - record[i] = row[col] + record[i] = Sanitize(row[col]) } if err := cw.Write(record); err != nil { return err @@ -228,6 +242,7 @@ func formatScalar(value string) string { } func formatTableCell(column, value string) string { + value = Sanitize(value) trimmed := strings.TrimSpace(value) if trimmed == "" { return value @@ -255,3 +270,32 @@ func formatTableCell(column, value string) string { } return value } + +// Sanitize replaces control characters in API-derived text with U+FFFD before it is +// written to a terminal. Token names and symbols are chosen by whoever deploys the +// contract, so a raw ESC could smuggle in an escape sequence such as OSC 52, which +// many terminals honour as "overwrite the clipboard". Whitespace controls (tab, +// newline, vertical tab, form feed, carriage return) are kept: they cannot start an +// escape sequence and occur in real data such as contract source code. JSON output +// does not need this, because the encoder escapes every control character. +func Sanitize(s string) string { + if utf8.ValidString(s) && strings.IndexFunc(s, unsafeControl) < 0 { + return s + } + // strings.Map also turns invalid UTF-8 bytes into U+FFFD, which covers a lone + // 8-bit C1 byte such as 0x9b (CSI). + return strings.Map(func(r rune) rune { + if unsafeControl(r) { + return utf8.RuneError + } + return r + }, s) +} + +func unsafeControl(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r': + return false + } + return unicode.IsControl(r) +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 5d459a0..93ca217 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -1,6 +1,11 @@ package output -import "testing" +import ( + "bytes" + "fmt" + "strings" + "testing" +) func TestRawValuePassthrough(t *testing.T) { const wei = "1500000000000000000" // 1.5 in 18-decimal units @@ -22,3 +27,96 @@ func TestRawValuePassthrough(t *testing.T) { t.Fatalf("formatTableCell tokenSymbol=%q, want USDC", got) } } + +// Pretty JSON must print numbers exactly as the API sent them. Decoding into +// float64 rounded 9007199254740993 (2^53+1) to ...992 and rewrote 1.50 as 1.5. +func TestWriteJSONKeepsExactNumbers(t *testing.T) { + var buf bytes.Buffer + if err := Write(&buf, []byte(`{"big":9007199254740993,"dec":1.50,"exp":1e3}`), JSON, false, nil); err != nil { + t.Fatal(err) + } + for _, want := range []string{`"big": 9007199254740993`, `"dec": 1.50`, `"exp": 1e3`} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %s:\n%s", want, buf.String()) + } + } +} + +// Token names and symbols are chosen by whoever deploys the contract. An OSC 52 +// sequence ("set the clipboard") in one must never reach the terminal raw. +func TestTextOutputStripsTerminalEscapes(t *testing.T) { + const osc52 = "\x1b]52;c;MHhCQUQ=\x07" + row := `{"tokenSymbol":"USDC` + jsonEscape(osc52) + `","tokenName":"` + jsonEscape("\x1b[32mUSD Coin\x1b[0m\u009b2J") + `"}` + cases := []struct { + name string + raw string + format Format + }{ + {"table", "[" + row + "]", Table}, + {"csv", "[" + row + "]", CSV}, + {"scalar", `"1000` + jsonEscape(osc52) + `"`, Table}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + if err := Write(&buf, []byte(tc.raw), tc.format, false, nil); err != nil { + t.Fatal(err) + } + if i := strings.IndexAny(buf.String(), "\x1b\x07\u009b"); i >= 0 { + t.Fatalf("raw control character at byte %d:\n%q", i, buf.String()) + } + if !strings.Contains(buf.String(), "\uFFFD") { + t.Fatalf("expected U+FFFD where the escape was:\n%q", buf.String()) + } + }) + } +} + +// JSON output stays raw data: the encoder escapes control characters itself. +func TestJSONOutputKeepsEscapedControls(t *testing.T) { + raw := []byte(`{"tokenSymbol":"USDC\u001b]52;c;x\u0007"}`) + for _, compact := range []bool{false, true} { + var buf bytes.Buffer + if err := Write(&buf, raw, JSON, compact, nil); err != nil { + t.Fatal(err) + } + if strings.ContainsAny(buf.String(), "\x1b\x07") || !strings.Contains(buf.String(), `\u001b]52;c;x\u0007`) { + t.Fatalf("compact=%v: JSON should keep the escaped form, got %q", compact, buf.String()) + } + } +} + +func TestSanitizeKeepsWhitespaceAndText(t *testing.T) { + for _, s := range []string{"pragma solidity ^0.8.0;\r\n\tcontract A {}\n", "page\fbreak\vtab", "πŸ‘¨β€πŸ’».eth", "ÜnΓ―cΓΆdΓ©", ""} { + if got := Sanitize(s); got != s { + t.Errorf("Sanitize(%q) = %q, want unchanged", s, got) + } + } + if got := Sanitize("a\x00b\x7fc\u0085d\xffe"); got != "a\uFFFDb\uFFFDc\uFFFDd\uFFFDe" { + t.Errorf("Sanitize replaced wrongly: %q", got) + } +} + +// Verified source code often uses CRLF line endings; CSV must keep them intact. +func TestCSVKeepsCRLFSource(t *testing.T) { + var buf bytes.Buffer + if err := Write(&buf, []byte(`[{"SourceCode":"line1\r\nline2\r\n"}]`), CSV, false, nil); err != nil { + t.Fatal(err) + } + if want := "SourceCode\n\"line1\r\nline2\r\n\"\n"; buf.String() != want { + t.Fatalf("csv = %q, want %q", buf.String(), want) + } +} + +// jsonEscape writes control characters as \u escapes, as an API response would. +func jsonEscape(s string) string { + var b strings.Builder + for _, r := range s { + if r < 0x20 || (r >= 0x7f && r <= 0x9f) { + fmt.Fprintf(&b, `\u%04x`, r) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a3aa341..ba2d92c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,6 +13,7 @@ import ( "fmt" "strconv" "strings" + "unicode/utf16" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" @@ -227,6 +228,10 @@ type model struct { keyErr string keyReturn viewState + // surrogate holds the first half of a UTF-16 surrogate pair until the second + // half arrives (see joinSurrogates). + surrogate rune + width, height int ready bool } @@ -299,7 +304,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case apiKeySavedMsg: m.keySaving = false if msg.err != nil { - m.keyErr = msg.err.Error() + m.keyErr = output.Sanitize(msg.err.Error()) m.keyInput.Focus() return m, textinput.Blink } @@ -336,6 +341,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + msg, ok := m.joinSurrogates(msg) + if !ok { + return m, nil + } switch m.state { case stateBrowse: return m.keyBrowse(msg) @@ -356,6 +365,42 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// joinSurrogates reassembles characters outside the Basic Multilingual Plane +// (emoji such as πŸ”₯) that arrive split on Windows. Bubble Tea v1 reads console +// input through coninput, which delivers one UTF-16 code unit per key event, so +// each half of a surrogate pair reaches us as its own KeyMsg and textinput would +// store both as U+FFFD. This mirrors the fix in Bubble Tea v2's input reader +// (charmbracelet/ultraviolet) and can be removed when migrating to v2. It reports +// false when the message only carried a buffered first half. +func (m *model) joinSurrogates(msg tea.KeyMsg) (tea.KeyMsg, bool) { + if msg.Type != tea.KeyRunes { + m.surrogate = 0 + return msg, true + } + runes := make([]rune, 0, len(msg.Runes)) + for _, r := range msg.Runes { + switch { + case r >= 0xD800 && r < 0xDC00: + // High (first) half: wait for its low half, replacing any stray one. + m.surrogate = r + case r >= 0xDC00 && r < 0xE000: + // Low (second) half: only meaningful right after a high half. + if m.surrogate != 0 { + runes = append(runes, utf16.DecodeRune(m.surrogate, r)) + } + m.surrogate = 0 + default: + m.surrogate = 0 + runes = append(runes, r) + } + } + if len(runes) == 0 { + return msg, false + } + msg.Runes = runes + return msg, true +} + func (m *model) keyBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": @@ -695,17 +740,17 @@ func (m *model) keyChainPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) setResult(raw json.RawMessage, err error) { m.state = stateResult if err != nil { - m.setContent(errSt.Render("error: " + err.Error())) + m.setContent(errSt.Render("error: " + output.Sanitize(err.Error()))) return } rows, scalar, rerr := output.Rows(raw) if rerr != nil { - m.setContent(errSt.Render("error: " + rerr.Error())) + m.setContent(errSt.Render("error: " + output.Sanitize(rerr.Error()))) return } if len(rows) == 0 { if strings.TrimSpace(scalar) != "" { - m.setContent(scalar) + m.setContent(output.Sanitize(scalar)) } else { m.setContent(descSt.Render("(no data)")) } @@ -713,7 +758,7 @@ func (m *model) setResult(raw json.RawMessage, err error) { } var buf bytes.Buffer if werr := output.WriteRows(&buf, rows, output.Table, m.current.Columns); werr != nil { - m.setContent(errSt.Render("error: " + werr.Error())) + m.setContent(errSt.Render("error: " + output.Sanitize(werr.Error()))) return } m.setContent(strings.TrimRight(buf.String(), "\n")) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index a562894..2b3c991 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "testing" + "unicode/utf16" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -490,6 +491,85 @@ func TestFormValidateHookInline(t *testing.T) { } } +// typeUTF16 replays s the way Bubble Tea v1 delivers console input on Windows: +// one KeyMsg per UTF-16 code unit, so emoji arrive as two lone surrogate halves. +func typeUTF16(m *model, s string) { + for _, u := range utf16.Encode([]rune(s)) { + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(u)}}) + } +} + +// TestFormJoinsWindowsSurrogatePairs: emoji typed or terminal-pasted on Windows +// reach the form field intact instead of as U+FFFD replacement characters. +func TestFormJoinsWindowsSurrogatePairs(t *testing.T) { + const name = "\U0001F468‍\U0001F4BB.eth" // πŸ‘¨β€πŸ’».eth, a ZWJ sequence + open := func() *model { + m := testModel(nil) + m.modIdx, m.focus, m.epIdx = 0, focusEndpoints, 0 + m.openSelected() + if m.state != stateForm { + t.Fatalf("expected form, got %v", m.state) + } + return m + } + + m := open() + typeUTF16(m, name) + if got := m.inputs[0].Value(); got != name { + t.Fatalf("split units: value = %q, want %q", got, name) + } + + // Both halves in one message (a key event with RepeatCount > 1 is split the same way). + m = open() + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{0xD83D, 0xDD25}}) + if got := m.inputs[0].Value(); got != "\U0001F525" { + t.Fatalf("one message: value = %q, want πŸ”₯", got) + } + + // A dangling first half is dropped rather than corrupting the next character. + m = open() + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{0xD83D}}) + typeRunes(m, "a") + if got := m.inputs[0].Value(); got != "a" { + t.Fatalf("dangling half: value = %q, want %q", got, "a") + } + // A stray half never swallows the valid pair that follows it: a repeated high + // half replaces the pending one, and an orphan low half is discarded. + for _, units := range [][]rune{{0xD83D, 0xD83D, 0xDD25}, {0xDD25, 0xD83D, 0xDD25}} { + m = open() + for _, u := range units { + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{u}}) + } + if got := m.inputs[0].Value(); got != "\U0001F525" { + t.Fatalf("units % X: value = %q, want πŸ”₯", units, got) + } + m = open() + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: units}) + if got := m.inputs[0].Value(); got != "\U0001F525" { + t.Fatalf("units % X in one message: value = %q, want πŸ”₯", units, got) + } + } + + // A non-rune key also discards a pending half. + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{0xD83D}}) + m.handleKey(tea.KeyMsg{Type: tea.KeyLeft}) + typeRunes(m, "b") + if strings.ContainsRune(m.inputs[0].Value(), 'οΏ½') { + t.Fatalf("pending half leaked after a non-rune key: %q", m.inputs[0].Value()) + } +} + +// TestChainPickerFilterJoinsWindowsSurrogatePairs: the chain filter reads runes +// straight from KeyMsg, so it gets the same reassembly as form inputs. +func TestChainPickerFilterJoinsWindowsSurrogatePairs(t *testing.T) { + m := chainPickerModel(func(string) (string, string, error) { return "", "", nil }) + typeRunes(m, "c") + typeUTF16(m, "\U0001F525") + if m.chainFilter != "\U0001F525" { + t.Fatalf("chain filter = %q, want πŸ”₯", m.chainFilter) + } +} + func chainPickerModel(switchChain func(string) (string, string, error)) *model { cfg := Config{ Endpoints: []Endpoint{{Module: "account", Action: "balance", Title: "balance"}},