Files
keyfunc/internal/cli/cli_test.go
T
sneak 1a9cc39dd2
check / check (push) Waiting to run
Check arguments before looking for the mnemonic (closes #79)
The key index, --words, the --to recipients and the input file of age encrypt and age decrypt were checked only after the mnemonic had been obtained, so a run that could not succeed still prompted for it or ran the mnemonic command. options.Index now refuses an index above derive.MaxIndex, which covers every command group; the mnemonic command calls the new childmnemonic.CheckWords first; age encrypt reads its recipients with the new agekey.Recipients, and both age commands open their input, before the key is derived. Encrypt now takes the parsed recipients. The README says which checks come first.

Model: opus-5-5
2026-10-14 18:26:16 +00:00

258 lines
7.1 KiB
Go

package cli_test
import (
"bytes"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"sneak.berlin/go/keyfunc/internal/bip39"
"sneak.berlin/go/keyfunc/internal/childmnemonic"
"sneak.berlin/go/keyfunc/internal/cli"
"sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/mnemonic"
)
// The two lines the README says the example mnemonic produces.
const (
vectorZero = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZOtOczrc/7CQytc" +
"uFwt7s4r8KjkZWkwjLZWBaFKD+7"
vectorOne = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEWY8+/gmHYVC4u0Y" +
"0I4FKs+eVUulTPHfk9VtXw1tMF"
)
// The child mnemonic the README says the example mnemonic gives at
// index 0.
const childZero = "prosper short ramp prepare exchange stove life " +
"snack client enough purpose fold"
// The two child mnemonic lengths the tests ask for.
const (
twelve = 12
twentyFour = 24
)
// tooLarge is the smallest key index with no hardened child.
const tooLarge = "2147483648"
// failingMnemonicCommand fails loudly, so a run that looks for the
// mnemonic ends with that failure. A run that ends with the refusal of
// an argument instead shows that the argument was checked first.
const failingMnemonicCommand = "echo the mnemonic was looked for >&2; exit 1"
// example returns the mnemonic the README gives its test vectors for:
// eleven abandons and about.
func example() string {
return strings.Repeat("abandon ", 11) + "about"
}
func TestTheReadmeTestVectors(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
require.Equal(t,
vectorZero+" keyfunc/ssh/0",
strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")),
)
require.Equal(t,
vectorOne+" keyfunc/ssh/1",
strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")),
)
require.Equal(t,
childZero, strings.TrimSpace(run(t, "mnemonic", "-n", "0")),
)
}
func TestTheSpacingBetweenTheWordsDoesNotChangeTheKeys(t *testing.T) {
words := strings.Fields(example())
for name, spaced := range map[string]string{
"one word per line": strings.Join(words, "\n"),
"double spaces": strings.Join(words, " "),
"tabs": strings.Join(words, "\t"),
} {
t.Run(name, func(t *testing.T) {
t.Setenv(mnemonic.Variable, spaced)
require.Equal(t,
vectorZero+" keyfunc/ssh/0",
strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")),
)
})
}
}
func TestTheCommentCanBeChosen(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
line := strings.TrimSpace(run(t, "ssh", "pub", "--comment", "mine"))
require.Equal(t, vectorZero+" mine", line)
}
func TestThePrivateKeyMatchesThePublicOne(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
block := run(t, "ssh", "priv", "-n", "1")
require.True(t,
strings.HasPrefix(block, "-----BEGIN OPENSSH PRIVATE KEY-----"),
)
parsed, err := ssh.ParsePrivateKey([]byte(block))
require.NoError(t, err)
back := strings.TrimSpace(
string(ssh.MarshalAuthorizedKey(parsed.PublicKey())),
)
require.Equal(t, vectorOne, back)
}
// The mnemonic command fails in these runs, so the refusal of the index
// shows that it was checked before the mnemonic was looked for. The age
// commands have a test of their own.
func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
for _, args := range [][]string{
{subcommand, "pub", "-n", tooLarge},
{subcommand, "priv", "-n", tooLarge},
{subcommand, installing, "-n", tooLarge, host},
{subcommand, "to", "-n", tooLarge, host},
{"mnemonic", "-n", tooLarge},
} {
name := strings.Join(args, " ")
out, err := execute(t, args...)
require.ErrorIs(t, err, derive.ErrIndexTooLarge, name)
require.Empty(t, out, name)
}
}
func TestAnIndexWithALeadingZeroIsDecimal(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
line := run(t, "ssh", "pub", "-n", "010")
require.Equal(t, run(t, "ssh", "pub", "-n", "10"), line)
require.True(t,
strings.HasSuffix(strings.TrimSpace(line), " keyfunc/ssh/10"),
)
}
// The mnemonic command in these runs fails, so a refusal naming the
// flag shows that the number was refused before the mnemonic was
// looked for.
func TestAnIndexThatIsNotDecimalDigitsIsRefused(t *testing.T) {
t.Parallel()
for _, index := range []string{"0x10", "1_0", "+1", "-1", ""} {
t.Run(index, func(t *testing.T) {
t.Parallel()
out, err := execute(t,
"ssh", "pub", "-n", index, "--mnemonic-command", "exit 1",
)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--index")
require.Empty(t, out)
})
}
}
// Neither a command group nor --version reads the index, so these
// show that it is refused while the flags are parsed.
func TestAnIndexNotInDecimalIsRefusedBeforeAnyCommandRuns(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"ssh", "-n", "0x10"},
{"-n", "0x10", "--version"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Parallel()
out, err := execute(t, args...)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--index")
require.Empty(t, out)
})
}
}
func TestTheMnemonicCommandIsUsed(t *testing.T) {
t.Setenv(mnemonic.Variable, "")
line := strings.TrimSpace(run(t,
"ssh", "pub",
"--mnemonic-command", "printf '%s\\n' '"+example()+"'",
))
require.Equal(t, vectorZero+" keyfunc/ssh/0", line)
}
func TestAChildMnemonicIsPrinted(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
short := strings.Fields(run(t, "mnemonic"))
require.Len(t, short, twelve)
require.True(t, bip39.IsMnemonicValid(strings.Join(short, " ")))
long := strings.Fields(run(t, "mnemonic", "--words", "24"))
require.Len(t, long, twentyFour)
next := strings.Fields(run(t, "mnemonic", "-n", "1"))
require.NotEqual(t, short, next)
}
// The mnemonic command fails in this run, so the refusal of the length
// shows that it was checked before the mnemonic was looked for.
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
out, err := execute(t, "mnemonic", "--words", "15")
require.ErrorIs(t, err, childmnemonic.ErrWordCount)
require.Empty(t, out)
}
func TestTheWordCountIsDecimal(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
words := strings.Fields(run(t, "mnemonic", "--words", "012"))
require.Len(t, words, twelve)
// As for the index, the failing mnemonic command shows that the
// number was refused first.
out, err := execute(t,
"mnemonic", "--words", "0x18", "--mnemonic-command", "exit 1",
)
require.ErrorIs(t, err, strconv.ErrSyntax)
require.ErrorContains(t, err, "--words")
require.Empty(t, out)
}
// run executes the tool with the given arguments and returns what it
// wrote to standard output.
func run(t *testing.T, args ...string) string {
t.Helper()
out, err := execute(t, args...)
require.NoError(t, err)
return out
}
// execute runs the tool and returns both what it wrote and how it
// ended.
func execute(t *testing.T, args ...string) (string, error) {
t.Helper()
var out bytes.Buffer
root := cli.Root()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.ExecuteContext(t.Context())
return out.String(), err
}