Add sutil CLI in cmd/sutil with internal/cli command tree

This commit is contained in:
2026-10-31 10:03:06 -07:00
parent c9ce5ff442
commit f53b6aad42
7 changed files with 551 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
// Command sutil is sneak's shell utility: small tools backed by the
// packages in sneak.berlin/go/x. Run `sutil --help` for the command list.
package main
import (
"os"
"sneak.berlin/go/x/internal/cli"
)
// version is the build version. The Makefile and Dockerfile set it at link
// time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
err := cli.New(cli.Params{Version: version}).Execute()
if err != nil {
os.Exit(1)
}
}
+82
View File
@@ -0,0 +1,82 @@
package cli
import (
"fmt"
"io"
"github.com/spf13/cobra"
"sneak.berlin/go/x/pkg/cbk"
)
// maxMnemonicInputBytes bounds how much of stdin `cbk derive` will read; a
// 24-word mnemonic is well under 300 bytes.
const maxMnemonicInputBytes = 64 << 10
// newCBKCommand returns `sutil cbk`, the parent of the crypto_box
// keypair commands.
func newCBKCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cbk",
Short: "crypto_box keypairs derived from BIP39 mnemonics",
}
cmd.AddCommand(newCBKDeriveCommand())
return cmd
}
// newCBKDeriveCommand returns `sutil cbk derive`.
func newCBKDeriveCommand() *cobra.Command {
var publicOnly bool
cmd := &cobra.Command{
Use: "derive",
Short: "Derive a crypto_box keypair from a mnemonic read on stdin",
Long: `Reads a BIP39 mnemonic from stdin, in any letter case and with any
whitespace between words, and prints the crypto_box keypair derived from it
as hex. The same mnemonic always yields the same keypair.
The mnemonic is read from stdin rather than taken as an argument so that it
does not end up in shell history or process listings:
sutil generate mnemonic > mnemonic.txt
sutil cbk derive < mnemonic.txt`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
input := io.LimitReader(cmd.InOrStdin(), maxMnemonicInputBytes)
mnemonic, err := io.ReadAll(input)
if err != nil {
return fmt.Errorf("reading mnemonic from stdin: %w", err)
}
keypair, err := cbk.New(cbk.Params{Mnemonic: string(mnemonic)})
if err != nil {
return err
}
return writeKeypair(cmd.OutOrStdout(), keypair, publicOnly)
},
}
cmd.Flags().BoolVar(&publicOnly, "public", false, "print only the public key")
return cmd
}
// writeKeypair prints the keypair as labeled hex lines, or just the bare
// public key when publicOnly is set.
func writeKeypair(w io.Writer, keypair cbk.CryptoBoxKeypair, publicOnly bool) error {
if publicOnly {
_, err := fmt.Fprintln(w, keypair.PublicKeyHex())
return err
}
_, err := fmt.Fprintf(
w, "public: %s\nprivate: %s\n", keypair.PublicKeyHex(), keypair.PrivateKeyHex(),
)
return err
}
+39
View File
@@ -0,0 +1,39 @@
// Package cli implements the sutil command tree.
//
// The tree is built by New rather than assembled in init functions, so
// the package holds no global state and tests can construct it repeatedly
// with captured input and output.
package cli
import (
"github.com/spf13/cobra"
)
// Params configures New.
type Params struct {
// Version is the version string embedded at build time. It is shown
// by `sutil version`, `sutil --version`, and `sutil info`.
Version string
}
// New builds the root sutil command with every subcommand attached.
func New(p Params) *cobra.Command {
root := &cobra.Command{
Use: "sutil",
Short: "sneak's shell utility",
Long: `sutil is a small collection of shell utilities built on the packages in
sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords,
BIP39 mnemonics, and crypto_box keypairs derived from them.`,
Version: p.Version,
SilenceUsage: true,
}
root.AddCommand(
newVersionCommand(p),
newInfoCommand(p),
newGenerateCommand(),
newCBKCommand(),
)
return root
}
+177
View File
@@ -0,0 +1,177 @@
package cli_test
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/x/internal/cli"
"sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk"
)
const (
testVersion = "v1.2.3-test"
// vectorRepeats is how many times "abandon" appears in the standard
// BIP39 test vector for all-zero 128-bit entropy, which ends with
// "about".
vectorRepeats = bip39wordlist.MinWordCount - 1
)
// vectorMnemonic returns the standard BIP39 test vector for all-zero
// 128-bit entropy.
func vectorMnemonic() string {
return strings.Repeat("abandon ", vectorRepeats) + "about"
}
func TestVersionCommand(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "version")
require.NoError(t, err)
assert.Equal(t, testVersion+"\n", output)
}
func TestVersionFlag(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "--version")
require.NoError(t, err)
assert.Contains(t, output, testVersion)
}
func TestInfoCommand(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "info")
require.NoError(t, err)
assert.Contains(t, output, "version: "+testVersion+"\n")
assert.Contains(t, output, "go: go")
assert.Contains(t, output, "module: sneak.berlin/go/x\n")
}
func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "4096", "seed")
require.NoError(t, err)
assert.Len(t, first, 4096)
second, err := execute(t, "", "generate", "random", "--count", "4096", "seed")
require.NoError(t, err)
assert.Equal(t, first, second)
other, err := execute(t, "", "gen", "random", "-c", "4096", "other seed")
require.NoError(t, err)
assert.NotEqual(t, first, other)
}
func TestGenerateRandomUnseeded(t *testing.T) {
t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "1024")
require.NoError(t, err)
assert.Len(t, first, 1024)
second, err := execute(t, "", "generate", "random", "--count", "1024")
require.NoError(t, err)
assert.NotEqual(t, first, second)
}
func TestGenerateRandomRejectsExtraArguments(t *testing.T) {
t.Parallel()
_, err := execute(t, "", "generate", "random", "one", "two")
require.Error(t, err)
}
func TestGenerateMnemonic(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "generate", "mnemonic")
require.NoError(t, err)
mnemonic := strings.TrimSuffix(output, "\n")
require.NoError(t, bip39wordlist.ValidateWordlist(mnemonic))
assert.Len(t, strings.Fields(mnemonic), bip39wordlist.MaxWordCount)
output, err = execute(t, "", "generate", "mnemonic", "--words", "12")
require.NoError(t, err)
assert.Len(t, strings.Fields(output), bip39wordlist.MinWordCount)
_, err = execute(t, "", "generate", "mnemonic", "--words", "13")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
}
func TestGeneratePassword(t *testing.T) {
t.Parallel()
_, err := pwgen.New(pwgen.Params{Words: nil, MinWordLength: 0, MaxWordLength: 0})
if errors.Is(err, pwgen.ErrNoUsableWords) {
t.Skip("this system has no word list")
}
require.NoError(t, err)
output, err := execute(t, "", "generate", "password")
require.NoError(t, err)
password := strings.TrimSuffix(output, "\n")
assert.NotContains(t, password, "\n")
assert.GreaterOrEqual(t, len(password), 3*(pwgen.DefaultMinWordLength+1)+2)
assert.True(t, strings.ContainsAny(password, "0123456789"), password)
assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password)
}
func TestCBKDerive(t *testing.T) {
t.Parallel()
expected, err := cbk.FromMnemonic(vectorMnemonic())
require.NoError(t, err)
stdin := " " + strings.ToUpper(vectorMnemonic()) + "\n"
output, err := execute(t, stdin, "cbk", "derive")
require.NoError(t, err)
assert.Equal(t,
"public: "+expected.PublicKeyHex()+"\n"+
"private: "+expected.PrivateKeyHex()+"\n",
output,
)
output, err = execute(t, vectorMnemonic(), "cbk", "derive", "--public")
require.NoError(t, err)
assert.Equal(t, expected.PublicKeyHex()+"\n", output)
}
func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) {
t.Parallel()
_, err := execute(t, "not a mnemonic\n", "cbk", "derive")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
}
// execute runs sutil with the given stdin and arguments and returns
// everything it wrote to stdout and stderr.
func execute(t *testing.T, stdin string, args ...string) (string, error) {
t.Helper()
root := cli.New(cli.Params{Version: testVersion})
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args)
err := root.Execute()
return output.String(), err
}
+157
View File
@@ -0,0 +1,157 @@
package cli
import (
"fmt"
"io"
"github.com/spf13/cobra"
"sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/internal/randstream"
"sneak.berlin/go/x/pkg/bip39wordlist"
)
// streamChunkSize is the write size used by `generate random`. Large
// writes keep the per-syscall overhead negligible.
const streamChunkSize = 1 << 20
// newGenerateCommand returns `sutil generate`, the parent of everything
// that produces random output.
func newGenerateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
Aliases: []string{"gen"},
Short: "Generate random bytes, passwords, and mnemonics",
}
cmd.AddCommand(
newGenerateRandomCommand(),
newGeneratePasswordCommand(),
newGenerateMnemonicCommand(),
)
return cmd
}
// newGenerateRandomCommand returns `sutil generate random [seedstr]`.
func newGenerateRandomCommand() *cobra.Command {
var byteCount int64
cmd := &cobra.Command{
Use: "random [seedstr]",
Short: "Write a fast stream of pseudorandom bytes to stdout",
Long: `Writes pseudorandom bytes to stdout until stopped, or until --count
bytes have been written.
With seedstr the stream is deterministic: the same seed always produces
the same bytes. Without it the stream is keyed from the operating
system's secure random source and is different every run.
This exists because /dev/urandom is slow.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
seed := ""
if len(args) == 1 {
seed = args[0]
}
stream, err := randstream.New(randstream.Params{Seed: seed})
if err != nil {
return err
}
return streamBytes(cmd.OutOrStdout(), stream, byteCount)
},
}
cmd.Flags().Int64VarP(
&byteCount, "count", "c", 0, "stop after this many bytes (0 = never)",
)
return cmd
}
// newGeneratePasswordCommand returns `sutil generate password`.
func newGeneratePasswordCommand() *cobra.Command {
return &cobra.Command{
Use: "password",
Aliases: []string{"pw"},
Short: "Print a memorable password built from system dictionary words",
Long: `Prints a password of the form
<word><digit><delim><word><digit><delim><word><digit>
for example marble7-sprout2-velvet9. The words come from the system word
list (/usr/share/dict/words), the delimiter is chosen at random from
` + pwgen.Delimiters + `
and is the same in both positions. The command panics if the system has
no usable word list.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
generator, err := pwgen.New(pwgen.Params{})
if err != nil {
panic("sutil: cannot load the system word list: " + err.Error())
}
password, err := generator.Generate()
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), password)
return err
},
}
}
// newGenerateMnemonicCommand returns `sutil generate mnemonic`.
func newGenerateMnemonicCommand() *cobra.Command {
var wordCount int
cmd := &cobra.Command{
Use: "mnemonic",
Short: "Print a new random BIP39 mnemonic phrase",
Long: `Prints a new BIP39 mnemonic phrase generated from crypto/rand. The
default of 24 words encodes 256 bits of entropy; 12 words encodes 128.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
return err
},
}
cmd.Flags().IntVarP(
&wordCount, "words", "w", bip39wordlist.MaxWordCount,
"number of words: 12, 15, 18, 21, or 24",
)
return cmd
}
// streamBytes copies limit bytes (or everything, when limit is zero) from
// src to dst in streamChunkSize chunks.
func streamBytes(dst io.Writer, src io.Reader, limit int64) error {
if limit > 0 {
src = io.LimitReader(src, limit)
}
// Wrapping dst hides any ReadFrom method it has, so io.CopyBuffer
// cannot bypass the large buffer.
_, err := io.CopyBuffer(writerOnly{dst}, src, make([]byte, streamChunkSize))
return err
}
// writerOnly exposes only the Write method of the wrapped writer.
type writerOnly struct {
io.Writer
}
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"errors"
"fmt"
"io"
"runtime/debug"
"strings"
"github.com/spf13/cobra"
)
// ErrNoBuildInfo is returned by `sutil info` when the binary carries no
// Go build information, which only happens for binaries not built with
// module support.
var ErrNoBuildInfo = errors.New("sutil: no build information in this binary")
// newInfoCommand returns `sutil info`, which prints the build version, the
// Go toolchain version, the module path, and the VCS metadata recorded by
// the Go toolchain at build time.
func newInfoCommand(p Params) *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Print build information",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return writeBuildInfo(cmd.OutOrStdout(), p.Version)
},
}
}
// writeBuildInfo writes one "key: value" line per fact to w.
func writeBuildInfo(w io.Writer, version string) error {
info, ok := debug.ReadBuildInfo()
if !ok {
return ErrNoBuildInfo
}
lines := []string{
"version: " + version,
"go: " + info.GoVersion,
"module: " + info.Main.Path,
}
for _, setting := range info.Settings {
if strings.HasPrefix(setting.Key, "vcs.") {
lines = append(lines, setting.Key+": "+setting.Value)
}
}
_, err := fmt.Fprintln(w, strings.Join(lines, "\n"))
return err
}
+22
View File
@@ -0,0 +1,22 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
// newVersionCommand returns `sutil version`, which prints the build
// version on its own line.
func newVersionCommand(p Params) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the sutil version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), p.Version)
return err
},
}
}