diff --git a/README.md b/README.md index 4372b8b..57b39bd 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,9 @@ m/83696968'/'/' to one tool. Each key type's number is spelled the way BIP-85 spells its own RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out): SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`). -- `n` is the key index: flag `--index` / `-n`, default `0`. +- `n` is the key index, a decimal number: flag `--index` / `-n`, default `0`. A + leading zero is still decimal (`-n 010` is index 10); a base prefix, an + underscore or a sign (`0x10`, `1_0`, `+1`) is refused. ## Giving it the mnemonic diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0b29adc..8113091 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "strconv" "strings" "testing" @@ -106,6 +107,56 @@ func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) { require.Empty(t, out) } +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, "") @@ -138,6 +189,22 @@ func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) { 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 { diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go index 8c33332..05fdcea 100644 --- a/internal/cli/mnemonic/mnemonic.go +++ b/internal/cli/mnemonic/mnemonic.go @@ -31,8 +31,9 @@ func Command() *cobra.Command { }, } - cmd.Flags().Uint32( - "words", childmnemonic.DefaultWords, + words := options.Decimal(childmnemonic.DefaultWords) + cmd.Flags().Var( + &words, "words", "how long the child mnemonic is: 12, 18 or 24 words", ) diff --git a/internal/cli/options/options.go b/internal/cli/options/options.go index 5d454ec..c008541 100644 --- a/internal/cli/options/options.go +++ b/internal/cli/options/options.go @@ -4,6 +4,7 @@ package options import ( "fmt" + "strconv" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/mnemonic" @@ -16,8 +17,8 @@ func Add(cmd *cobra.Command) { "mnemonic-command", "", "shell command whose output is the mnemonic", ) - cmd.PersistentFlags().Uint32P( - "index", "n", 0, + cmd.PersistentFlags().VarP( + new(Decimal), "index", "n", "which key to derive", ) } @@ -41,3 +42,33 @@ func Mnemonic(cmd *cobra.Command) (string, error) { return mnemonic.Read(cmd.Context(), command) } + +// Decimal is a flag value for a number written in decimal digits only. +// The flag library's own uint32 flags read 010 as octal 8 and also +// take 0x10 or 1_0. +type Decimal uint32 + +// String returns the number in decimal digits. +func (d *Decimal) String() string { + return strconv.FormatUint(uint64(*d), 10) +} + +// Set reads text as a decimal number that fits in 32 bits: a leading +// zero is still decimal, and a base prefix, an underscore or a sign is +// refused. +func (d *Decimal) Set(text string) error { + number, err := strconv.ParseUint(text, 10, 32) + if err != nil { + return err + } + + *d = Decimal(number) + + return nil +} + +// Type is what --help shows as the flag's type, and what GetUint32 +// checks before it reads the value back. +func (d *Decimal) Type() string { + return "uint32" +}