Compare commits
3
Commits
ca1faa5551
...
a31a03b2c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a31a03b2c3 | ||
|
|
d4fbcbc83d | ||
|
|
897b43a206 |
@@ -106,9 +106,8 @@ order; the first one found wins:
|
|||||||
|
|
||||||
1. `--mnemonic-command <command>`: a shell command, run with `sh -c`, whose
|
1. `--mnemonic-command <command>`: a shell command, run with `sh -c`, whose
|
||||||
standard output is the mnemonic. Example:
|
standard output is the mnemonic. Example:
|
||||||
`--mnemonic-command 'secret get foo'`. Whitespace around the output is
|
`--mnemonic-command 'secret get foo'`. If the command exits with a non-zero
|
||||||
dropped. If the command exits with a non-zero status, the tool prints its
|
status, the tool prints its standard error and exits with status 1.
|
||||||
standard error and exits with status 1.
|
|
||||||
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command
|
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command
|
||||||
held in the environment.
|
held in the environment.
|
||||||
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
|
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
|
||||||
@@ -116,7 +115,9 @@ order; the first one found wins:
|
|||||||
|
|
||||||
If none of these is available and standard input is not a terminal, the tool
|
If none of these is available and standard input is not a terminal, the tool
|
||||||
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
|
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
|
||||||
refused with a message saying so.
|
refused with a message saying so. Keys are derived from the mnemonic's words
|
||||||
|
joined by single spaces, whatever whitespace is around or between them, so one
|
||||||
|
word per line, tabs or extra spaces give the same keys.
|
||||||
|
|
||||||
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
|
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
|
||||||
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
|
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
|
||||||
@@ -162,21 +163,23 @@ Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
|
|||||||
on the host: the file is fetched, changed here, and written back with the system
|
on the host: the file is fetched, changed here, and written back with the system
|
||||||
`sftp` client in batch mode.
|
`sftp` client in batch mode.
|
||||||
|
|
||||||
The first connection lists `~/.ssh` and then fetches `~/.ssh/authorized_keys`
|
The first connection lists `~/.ssh`, then `~/.ssh/.`, and then fetches
|
||||||
from it. The file reads as empty in two cases only: `sftp` reported `~/.ssh`
|
`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp`
|
||||||
itself as not being there, or the listing came up and the file was not in it.
|
reported `~/.ssh` itself as not being there, or both listings came up and the
|
||||||
Any other outcome of that connection fails the run — a `~/.ssh` that is there
|
file was not found. Any other outcome of that connection fails the run — a
|
||||||
but cannot be entered, an `authorized_keys` that is there but cannot be read, or
|
`~/.ssh` that is there but cannot be read or entered, an `authorized_keys` that
|
||||||
a connection that did not come up — and the tool prints what `sftp` said and
|
is there but cannot be read, or a connection that did not come up — and the tool
|
||||||
exits with status 1 without writing anything, rather than put a file back
|
prints what `sftp` said and exits with status 1 without writing anything, rather
|
||||||
holding the new key alone. The listing is what tells a missing directory from
|
than put a file back holding the new key alone. The listings are what tell a
|
||||||
one shut to the user, which `sftp` reports on a fetch the same way; the wording
|
missing directory from one shut to the user, which `sftp` reports on a fetch the
|
||||||
of a missing file elsewhere does not count either, since `ssh` writes
|
same way: one that cannot be read fails the first listing, and one that can be
|
||||||
`No such file or directory` about an `-i` it cannot find on a session that then
|
read but not entered fails the second, after which the tool says that `~/.ssh`
|
||||||
authenticates through the agent. If an identical line is already in the file,
|
cannot be entered. The wording of a missing file elsewhere does not count
|
||||||
the tool prints `already present` and connects no further. Otherwise the line is
|
either, since `ssh` writes `No such file or directory` about an `-i` it cannot
|
||||||
added (after a newline, if the file did not end with one) and a second
|
find on a session that then authenticates through the agent. If an identical
|
||||||
connection:
|
line is already in the file, the tool prints `already present` and connects no
|
||||||
|
further. Otherwise the line is added (after a newline, if the file did not end
|
||||||
|
with one) and a second connection:
|
||||||
|
|
||||||
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection
|
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection
|
||||||
found none; a `~/.ssh` that was already there keeps the mode it had;
|
found none; a `~/.ssh` that was already there keeps the mode it had;
|
||||||
@@ -199,7 +202,10 @@ error, so the tool's own standard output is only `added` or `already present`.
|
|||||||
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
|
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
|
||||||
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
|
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
|
||||||
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
|
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
|
||||||
has to do it, not a typed password.
|
has to do it, not a typed password. Nor does it ask whether to trust a host key
|
||||||
|
it has not seen, so the host has to be in `known_hosts` already, or the run
|
||||||
|
fails with `Host key verification failed`. Connect to the host once with `ssh`
|
||||||
|
first, or pass `-o StrictHostKeyChecking=accept-new` after `--`.
|
||||||
|
|
||||||
### `keyfunc ssh to <host> [ssh arguments...]`
|
### `keyfunc ssh to <host> [ssh arguments...]`
|
||||||
|
|
||||||
@@ -260,10 +266,18 @@ A child mnemonic is a full mnemonic in its own right: it can seed another
|
|||||||
`keyfunc`, another wallet, or `secret`, and it never has to be written down,
|
`keyfunc`, another wallet, or `secret`, and it never has to be written down,
|
||||||
since it can be derived again.
|
since it can be derived again.
|
||||||
|
|
||||||
Test vector: the child-mnemonic step is checked against BIP-85's own published
|
Test vector, mnemonic
|
||||||
vectors, which derive from the specification's master key
|
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
|
||||||
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`.
|
|
||||||
At key index 0 the 12-word English child mnemonic is:
|
```
|
||||||
|
index 0: prosper short ramp prepare exchange stove life snack client enough purpose fold
|
||||||
|
```
|
||||||
|
|
||||||
|
The child-mnemonic step is also checked against BIP-85's own published vectors.
|
||||||
|
Those start from the specification's master key
|
||||||
|
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`
|
||||||
|
rather than from a mnemonic, so they cannot be given to `keyfunc`; at key index
|
||||||
|
0 the 12-word English child mnemonic of that key is:
|
||||||
|
|
||||||
```
|
```
|
||||||
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
||||||
@@ -274,6 +288,15 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
|||||||
Errors go to standard error and the exit status is 1, except for `ssh to`, which
|
Errors go to standard error and the exit status is 1, except for `ssh to`, which
|
||||||
passes through `ssh`'s own exit status.
|
passes through `ssh`'s own exit status.
|
||||||
|
|
||||||
|
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
|
||||||
|
with the status a shell gives a program killed by that signal (130 for SIGINT).
|
||||||
|
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
|
||||||
|
the unfinished file it was writing, leaves a file already at the named path as
|
||||||
|
it was, and exits with status 1. While `ssh to` or `ssh install` has `ssh` or
|
||||||
|
`sftp` running, the signal ends that program instead, the tool removes its agent
|
||||||
|
socket or working files, and it exits with status 1, or for `ssh to` with
|
||||||
|
`ssh`'s own status if `ssh` reported one.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
The repo adheres to the
|
The repo adheres to the
|
||||||
|
|||||||
+25
-1
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||||
@@ -185,16 +187,38 @@ func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From before the new file is made until it is renamed or removed,
|
||||||
|
// a signal removes it and ends the tool with status 1, even while
|
||||||
|
// the work is blocked reading its input, so an interrupted run
|
||||||
|
// leaves no file.
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
|
||||||
// The file is made in the same directory so that putting it in
|
// The file is made in the same directory so that putting it in
|
||||||
// place is a rename and never a copy, and it is readable only by
|
// place is a rename and never a copy, and it is readable only by
|
||||||
// its owner, which is the mode it keeps once renamed.
|
// its owner, which is the mode it keeps once renamed.
|
||||||
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
|
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
signal.Stop(signals)
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
|
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, received := <-signals; received {
|
||||||
|
_ = os.Remove(file.Name())
|
||||||
|
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return file, func(failed error) error {
|
return file, func(failed error) error {
|
||||||
return finish(file, name, failed)
|
finished := finish(file, name, failed)
|
||||||
|
|
||||||
|
signal.Stop(signals)
|
||||||
|
close(signals)
|
||||||
|
|
||||||
|
return finished
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||||
@@ -90,6 +94,78 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
|||||||
require.Equal(t, "what was already there\n", string(kept))
|
require.Equal(t, "what was already there\n", string(kept))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
for _, ending := range []os.Signal{
|
||||||
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||||
|
} {
|
||||||
|
interrupted(t, ending, "encrypt", "the start of the secret\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
// All of an encryption but its last byte, so the tool reads the
|
||||||
|
// header and then waits for the rest.
|
||||||
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
||||||
|
cut := sealed[:len(sealed)-1]
|
||||||
|
|
||||||
|
for _, ending := range []os.Signal{
|
||||||
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||||
|
} {
|
||||||
|
interrupted(t, ending, "decrypt", cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
||||||
|
// operation says, as a subprocess writing into a directory of its own
|
||||||
|
// and reading the input from a pipe that stays open. It waits until the
|
||||||
|
// tool has begun writing the file beside the one it was named, and
|
||||||
|
// sends it the signal. The tool has to end on the signal alone, with
|
||||||
|
// status 1, and leave the directory empty. A tool that went on reading
|
||||||
|
// would not end until the input did; one that did not remove the file
|
||||||
|
// it was writing would leave it there, with what it had written so far.
|
||||||
|
func interrupted(t *testing.T, signal os.Signal, operation, input string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
name := operation + " " + signal.String()
|
||||||
|
directory := t.TempDir()
|
||||||
|
|
||||||
|
//nolint:gosec // the binary is this test's own, re-run as the tool
|
||||||
|
command := exec.CommandContext(
|
||||||
|
t.Context(), os.Args[0], "age", operation,
|
||||||
|
"-o", filepath.Join(directory, "notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||||
|
|
||||||
|
producer, err := command.StdinPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, command.Start())
|
||||||
|
|
||||||
|
_, err = io.WriteString(producer, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The file beside the named one is made once the mnemonic has been
|
||||||
|
// read, before any input is.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
|
||||||
|
return err == nil && len(entries) > 0
|
||||||
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(signal))
|
||||||
|
waitForTool(t, name, command)
|
||||||
|
|
||||||
|
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
||||||
|
|
||||||
|
left, err := os.ReadDir(directory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, left, name)
|
||||||
|
}
|
||||||
|
|
||||||
// written puts the contents in a file of that name in a directory of
|
// written puts the contents in a file of that name in a directory of
|
||||||
// this test's own and returns the path to it.
|
// this test's own and returns the path to it.
|
||||||
func written(t *testing.T, name, contents string) string {
|
func written(t *testing.T, name, contents string) string {
|
||||||
|
|||||||
+8
-14
@@ -2,13 +2,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/keyfunc/internal/cli/age"
|
"sneak.berlin/go/keyfunc/internal/cli/age"
|
||||||
@@ -70,18 +67,15 @@ func Root() *cobra.Command {
|
|||||||
// ended with. ssh has already said whatever it had to say in that
|
// ended with. ssh has already said whatever it had to say in that
|
||||||
// case, so nothing more is printed.
|
// case, so nothing more is printed.
|
||||||
//
|
//
|
||||||
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
|
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
|
||||||
// killing the process outright, so the child ssh or sftp ends and the
|
// program, so a command waiting at the mnemonic prompt or reading what
|
||||||
// deferred cleanup that removes the agent socket and the install
|
// it encrypts or decrypts goes no further. The exceptions catch the
|
||||||
// working directory still runs.
|
// signals to clean up first: "ssh to" and "ssh install" while they
|
||||||
|
// have ssh or sftp running, so the child ends and their own cleanup
|
||||||
|
// still runs, and "age encrypt -o" and "age decrypt -o" while they
|
||||||
|
// write, so the unfinished file is removed.
|
||||||
func Main() int {
|
func Main() int {
|
||||||
ctx, stop := signal.NotifyContext(
|
err := Root().Execute()
|
||||||
context.Background(),
|
|
||||||
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
|
||||||
)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
err := Root().ExecuteContext(ctx)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const (
|
|||||||
"0I4FKs+eVUulTPHfk9VtXw1tMF"
|
"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.
|
// The two child mnemonic lengths the tests ask for.
|
||||||
const (
|
const (
|
||||||
twelve = 12
|
twelve = 12
|
||||||
@@ -45,6 +50,28 @@ func TestTheReadmeTestVectors(t *testing.T) {
|
|||||||
vectorOne+" keyfunc/ssh/1",
|
vectorOne+" keyfunc/ssh/1",
|
||||||
strings.TrimSpace(run(t, "ssh", "pub", "-n", "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) {
|
func TestTheCommentCanBeChosen(t *testing.T) {
|
||||||
|
|||||||
+50
-21
@@ -4,12 +4,15 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -32,6 +35,12 @@ const (
|
|||||||
localMode = 0o600
|
localMode = 0o600
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrCannotEnter is the refusal of a host whose .ssh is there but
|
||||||
|
// cannot be entered, so that nothing in it can be read or written.
|
||||||
|
var ErrCannotEnter = errors.New(
|
||||||
|
"~/.ssh is there on the host but cannot be entered",
|
||||||
|
)
|
||||||
|
|
||||||
// install returns the command that adds the public key to a host.
|
// install returns the command that adds the public key to a host.
|
||||||
func install() *cobra.Command {
|
func install() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -55,6 +64,17 @@ func install() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From here on a signal cancels the context, which
|
||||||
|
// sftp runs under, instead of ending the tool, so sftp
|
||||||
|
// ends and the working directory is still removed.
|
||||||
|
ctx, stop := signal.NotifyContext(
|
||||||
|
cmd.Context(),
|
||||||
|
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
|
||||||
return add(cmd, args[0], args[1:], line)
|
return add(cmd, args[0], args[1:], line)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -199,30 +219,39 @@ func merge(content, line string) (string, bool) {
|
|||||||
|
|
||||||
// fetch brings the host's authorized_keys into the given path and
|
// fetch brings the host's authorized_keys into the given path and
|
||||||
// returns what is in it, and whether the .ssh directory was already
|
// returns what is in it, and whether the .ssh directory was already
|
||||||
// there. The one session lists .ssh and then gets the file, so the
|
// there. The one session lists .ssh, then .ssh/., and then gets the
|
||||||
// listing settles the state of the directory before the get is read.
|
// file, so the listings settle the state of the directory before the
|
||||||
|
// get is read.
|
||||||
//
|
//
|
||||||
// The file reads as empty in just two cases: sftp reported .ssh itself
|
// The file reads as empty in just two cases: sftp reported .ssh itself
|
||||||
// as not there, or the listing succeeded and the get then reported the
|
// as not there, or both listings succeeded and the get then reported
|
||||||
// file as not there. Anything else — the listing refused, the file
|
// the file as not there. Anything else — a listing refused, the file
|
||||||
// there but unreadable, the connection down — fails the run and writes
|
// there but unreadable, the connection down — fails the run and writes
|
||||||
// nothing, because writing back over what was not read would leave the
|
// nothing, because writing back over what was not read would leave the
|
||||||
// host with the new key and nothing else. sftp cannot tell a missing
|
// host with the new key and nothing else. sftp cannot tell a missing
|
||||||
// file from one in a directory it cannot enter, so the listing does:
|
// file from one in a directory it cannot enter, so the listings do: a
|
||||||
// a directory that is there but cannot be read is a failure, not an
|
// directory that is there but cannot be read fails the first, and one
|
||||||
// empty file.
|
// that can be read but not entered fails the second, because nothing in
|
||||||
|
// it can be looked up, not even ".". The first listing of such a
|
||||||
|
// directory comes up empty, as the server leaves out every name it
|
||||||
|
// cannot look up.
|
||||||
func fetch(
|
func fetch(
|
||||||
cmd *cobra.Command, host string, options []string, into string,
|
cmd *cobra.Command, host string, options []string, into string,
|
||||||
) (string, bool, error) {
|
) (string, bool, error) {
|
||||||
said, err := session(cmd, host, options, []string{
|
said, err := session(cmd, host, options, []string{
|
||||||
"ls -1 " + directory,
|
"ls -1 " + directory,
|
||||||
|
"ls -1 " + directory + "/.",
|
||||||
"get " + authorized + " " + quoted(into),
|
"get " + authorized + " " + quoted(into),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if directoryAbsent(said) {
|
if listingNotFound(said, directory) {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if listingNotFound(said, directory+"/.") {
|
||||||
|
return "", false, ErrCannotEnter
|
||||||
|
}
|
||||||
|
|
||||||
if absent(said) {
|
if absent(said) {
|
||||||
return "", true, nil
|
return "", true, nil
|
||||||
}
|
}
|
||||||
@@ -239,18 +268,18 @@ func fetch(
|
|||||||
return string(content), true, nil
|
return string(content), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// directoryAbsent says whether sftp reported .ssh itself as not being
|
// listingNotFound says whether sftp reported the path it was asked to
|
||||||
// there, which is the one listing failure read as a host that has no
|
// list as not being there. For .ssh that is the one listing failure
|
||||||
// authorized_keys yet. The reading is taken only from the line in which
|
// read as a host that has no authorized_keys yet; for .ssh/., once .ssh
|
||||||
// sftp reports on that directory: any other failure of the listing, in
|
// itself has been listed, it is a .ssh that is there but cannot be
|
||||||
// particular a directory that is there but cannot be entered, is left
|
// entered. The reading is taken only from the line in which sftp
|
||||||
// as a failure, so that no key is written to a host whose keys were
|
// reports on that path: any other failure of a listing, in particular a
|
||||||
// never read.
|
// directory that is there but cannot be read, is left as a failure, so
|
||||||
func directoryAbsent(said string) bool {
|
// that no key is written to a host whose keys were never read.
|
||||||
|
func listingNotFound(said, path string) bool {
|
||||||
for line := range strings.Lines(said) {
|
for line := range strings.Lines(said) {
|
||||||
named, is := reportedCannotList(strings.TrimSpace(line))
|
named, is := reportedCannotList(strings.TrimSpace(line))
|
||||||
if is && (named == directory ||
|
if is && (named == path || strings.HasSuffix(named, "/"+path)) {
|
||||||
strings.HasSuffix(named, "/"+directory)) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,9 +288,9 @@ func directoryAbsent(said string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reportedCannotList returns the path an sftp line reports it cannot
|
// reportedCannotList returns the path an sftp line reports it cannot
|
||||||
// list for want of the directory, and whether the line is such a
|
// list for want of it, and whether the line is such a report. The
|
||||||
// report. The client writes this one wording when the directory a
|
// client writes this one wording when it cannot look up the path a
|
||||||
// listing names is not there, giving the path the server expanded.
|
// listing names, giving the path the server expanded.
|
||||||
func reportedCannotList(line string) (string, bool) {
|
func reportedCannotList(line string) (string, bool) {
|
||||||
const (
|
const (
|
||||||
before = `Can't ls: "`
|
before = `Can't ls: "`
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
|
|||||||
|
|
||||||
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
|
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
|
||||||
// wordings the OpenSSH client was seen to use when a listing fails: a
|
// wordings the OpenSSH client was seen to use when a listing fails: a
|
||||||
// directory it cannot find is reported one way, and one it cannot enter
|
// directory it cannot find is reported one way, and one it cannot read
|
||||||
// another, and only the first is read as a host with no .ssh yet.
|
// another, and only the first is read as a host with no .ssh yet. A
|
||||||
|
// .ssh that can be read but not entered lists as empty, and the
|
||||||
|
// listing of .ssh/. that follows reports that path, not .ssh, as not
|
||||||
|
// found.
|
||||||
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -102,11 +105,16 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
|||||||
`Can't ls: "/home/someone/.ssh" not found` + "\n",
|
`Can't ls: "/home/someone/.ssh" not found` + "\n",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
"the directory is there and cannot be entered": {
|
"the directory is there and cannot be read": {
|
||||||
said: listed +
|
said: listed +
|
||||||
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
|
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
"the directory is there and cannot be entered": {
|
||||||
|
said: listed + "sftp> ls -1 .ssh/.\n" +
|
||||||
|
`Can't ls: "/home/someone/.ssh/." not found` + "\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
"some other directory is not there": {
|
"some other directory is not there": {
|
||||||
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
|
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
|
||||||
want: false,
|
want: false,
|
||||||
@@ -122,7 +130,7 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if directoryAbsent(listing.said) != listing.want {
|
if listingNotFound(listing.said, directory) != listing.want {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"read as absent: %t, wanted %t, from:\n%s",
|
"read as absent: %t, wanted %t, from:\n%s",
|
||||||
!listing.want, listing.want, listing.said,
|
!listing.want, listing.want, listing.said,
|
||||||
|
|||||||
+14
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -42,7 +43,17 @@ func to() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
served, err := key.Serve(cmd.Context(), comment)
|
// From here until the agent is taken down, a signal
|
||||||
|
// cancels the context instead of ending the tool, so
|
||||||
|
// ssh ends and the socket and its directory are still
|
||||||
|
// removed.
|
||||||
|
ctx, stop := signal.NotifyContext(
|
||||||
|
cmd.Context(),
|
||||||
|
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
served, err := key.Serve(ctx, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +64,7 @@ func to() *cobra.Command {
|
|||||||
"-o", "IdentityAgent=" + served.Socket(),
|
"-o", "IdentityAgent=" + served.Socket(),
|
||||||
}, args)
|
}, args)
|
||||||
|
|
||||||
return connect(cmd.Context(), argv)
|
return connect(ctx, argv)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +87,7 @@ func connect(ctx context.Context, argv []string) error {
|
|||||||
command.Stdout = os.Stdout
|
command.Stdout = os.Stdout
|
||||||
command.Stderr = os.Stderr
|
command.Stderr = os.Stderr
|
||||||
|
|
||||||
// A cancelled context means a signal ended the tool. Send ssh a
|
// A cancelled context means a signal arrived. Send ssh a
|
||||||
// SIGTERM rather than the default kill, so it puts the terminal
|
// SIGTERM rather than the default kill, so it puts the terminal
|
||||||
// back the way it found it before it goes.
|
// back the way it found it before it goes.
|
||||||
command.Cancel = func() error {
|
command.Cancel = func() error {
|
||||||
|
|||||||
+117
-23
@@ -20,13 +20,13 @@ import (
|
|||||||
|
|
||||||
// runAsTool, set in the environment of a re-executed test binary, tells
|
// runAsTool, set in the environment of a re-executed test binary, tells
|
||||||
// TestMain to run the tool through Main rather than the suite, so the
|
// TestMain to run the tool through Main rather than the suite, so the
|
||||||
// signal test can drive the real signal path in a process it can send a
|
// signal tests can drive the real signal path in a process they can
|
||||||
// signal to.
|
// send a signal to.
|
||||||
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
||||||
|
|
||||||
// TestMain re-executes the test binary as the tool when runAsTool is
|
// TestMain re-executes the test binary as the tool when runAsTool is
|
||||||
// set, and otherwise runs the suite. The signal test starts the tool
|
// set, and otherwise runs the suite. The signal tests start the tool
|
||||||
// this way, as a subprocess it can signal and watch clean up.
|
// this way, as a subprocess they can signal and watch end.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
if os.Getenv(runAsTool) == "1" {
|
if os.Getenv(runAsTool) == "1" {
|
||||||
os.Exit(cli.Main())
|
os.Exit(cli.Main())
|
||||||
@@ -52,7 +52,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// notADirectory is what a test puts where the .ssh directory belongs
|
// notADirectory is what a test puts where the .ssh directory belongs
|
||||||
// to make a step of the write session fail.
|
// to make a .ssh that is listed but cannot be entered.
|
||||||
const notADirectory = "a file where the directory belongs\n"
|
const notADirectory = "a file where the directory belongs\n"
|
||||||
|
|
||||||
// missingIdentity is a path with no file at it, handed to sftp after
|
// missingIdentity is a path with no file at it, handed to sftp after
|
||||||
@@ -106,9 +106,11 @@ const marker = "KEYFUNC_TEST_MARKER"
|
|||||||
// another for a file that is there and cannot be read, which is a
|
// another for a file that is there and cannot be read, which is a
|
||||||
// failure. A directory shut to the user is stood in for by mode 000,
|
// failure. A directory shut to the user is stood in for by mode 000,
|
||||||
// which the listing reads off the mode itself so that the test does not
|
// which the listing reads off the mode itself so that the test does not
|
||||||
// turn on the user it runs as. An -i naming a file that is not here
|
// turn on the user it runs as, and one the user can enter but not write
|
||||||
// draws the warning ssh writes for it, which carries the wording of a
|
// to by mode 500, which the put reads off the same way. An -i naming a
|
||||||
// missing file into a session that goes on to authenticate.
|
// file that is not here draws the warning ssh writes for it, which
|
||||||
|
// carries the wording of a missing file into a session that goes on to
|
||||||
|
// authenticate.
|
||||||
const installer = `
|
const installer = `
|
||||||
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
||||||
previous=
|
previous=
|
||||||
@@ -160,7 +162,13 @@ while IFS= read -r line; do
|
|||||||
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
|
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
put)
|
||||||
|
if [ "$(stat -c '%a' "$(dirname "$home/$3")")" = 500 ]; then
|
||||||
|
worked=no
|
||||||
|
else
|
||||||
|
cp "$2" "$home/$3" 2>/dev/null || worked=no
|
||||||
|
fi
|
||||||
|
;;
|
||||||
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
|
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
|
||||||
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
|
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
@@ -201,6 +209,16 @@ fi
|
|||||||
sleep 5
|
sleep 5
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// stalled is a stand-in for the system sftp that notes it has started
|
||||||
|
// and then blocks, so a test can signal the tool while sftp is running
|
||||||
|
// and watch it remove its working directory. The exec keeps the shell
|
||||||
|
// from leaving a sleep behind that holds the output the tool reads sftp
|
||||||
|
// through.
|
||||||
|
const stalled = `
|
||||||
|
touch "$KEYFUNC_TEST_STARTED"
|
||||||
|
exec sleep 5
|
||||||
|
`
|
||||||
|
|
||||||
// pretended is where a stand-in writes down what it was asked to do.
|
// pretended is where a stand-in writes down what it was asked to do.
|
||||||
type pretended struct {
|
type pretended struct {
|
||||||
// home stands in for the home directory on the host.
|
// home stands in for the home directory on the host.
|
||||||
@@ -329,6 +347,29 @@ func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
|
|||||||
require.Equal(t, 1, connections(t, pretend))
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
// A file where .ssh belongs is listed and cannot be entered, which is
|
||||||
|
// how sftp sees a directory that can be read but not entered: the
|
||||||
|
// listing of .ssh comes up and the listing of .ssh/. finds nothing.
|
||||||
|
inTheWay := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
printed, _, err := attempt(t, host)
|
||||||
|
require.ErrorIs(t, err, ssh.ErrCannotEnter)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
|
||||||
|
// The read and nothing after it: no upload was tried, and what was
|
||||||
|
// on the host is still what is on the host.
|
||||||
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
|
require.Equal(t, notADirectory, read(t, inTheWay))
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
@@ -392,27 +433,30 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
|||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
// A file where the .ssh directory belongs: the listing shows it and
|
// A .ssh that can be listed and entered but not written to: the
|
||||||
// so the directory reads as already there, but then the put has
|
// fetch finds no file in it, and then the put has nowhere to put
|
||||||
// nowhere to put anything, so the write session ends at the put.
|
// anything, so the write session ends at the put.
|
||||||
inTheWay := filepath.Join(pretend.home, keptUnder)
|
const unwritable = 0o500
|
||||||
require.NoError(t,
|
|
||||||
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
directory := filepath.Join(pretend.home, keptUnder)
|
||||||
)
|
require.NoError(t, os.Mkdir(directory, unwritable))
|
||||||
|
|
||||||
printed, said, err := attempt(t, host)
|
printed, said, err := attempt(t, host)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Empty(t, printed)
|
require.Empty(t, printed)
|
||||||
require.Contains(t, said, "put failed")
|
require.Contains(t, said, "put failed")
|
||||||
|
|
||||||
// The put is the first and last command the write session got to,
|
// The put, after the three commands of the fetch, is the first and
|
||||||
// and the file it was uploading is the one the message names.
|
// last command the write session got to, and the file it was
|
||||||
|
// uploading is the one the message names.
|
||||||
sent := recorded(t, pretend.batch)
|
sent := recorded(t, pretend.batch)
|
||||||
require.Len(t, sent, 3)
|
require.Len(t, sent, 4)
|
||||||
require.Equal(t, "put", strings.Fields(sent[2])[0])
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||||
require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
|
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
|
||||||
|
|
||||||
require.Equal(t, notADirectory, read(t, inTheWay))
|
left, err := os.ReadDir(directory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, left)
|
||||||
|
|
||||||
// The same run again, this way for the status it ends with.
|
// The same run again, this way for the status it ends with.
|
||||||
given := os.Args
|
given := os.Args
|
||||||
@@ -510,7 +554,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
|||||||
// ssh that blocks, waits until the agent is up and ssh is running
|
// ssh that blocks, waits until the agent is up and ssh is running
|
||||||
// against it, sends the tool the signal, and requires the agent socket
|
// against it, sends the tool the signal, and requires the agent socket
|
||||||
// and its directory to be gone once the tool has ended. The subprocess
|
// and its directory to be gone once the tool has ended. The subprocess
|
||||||
// goes through Main and its signal handling, so with that handling
|
// goes through Main and the command's signal handling, so with that handling
|
||||||
// removed the signal kills the tool outright, no deferred cleanup runs,
|
// removed the signal kills the tool outright, no deferred cleanup runs,
|
||||||
// the directory is left behind, and the check fails.
|
// the directory is left behind, and the check fails.
|
||||||
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
||||||
@@ -577,6 +621,56 @@ func waitForSocket(t *testing.T, noted string) string {
|
|||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
for _, ending := range []os.Signal{
|
||||||
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||||
|
} {
|
||||||
|
signalEndsTheInstall(t, ending.String(), ending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signalEndsTheInstall runs "ssh install" as a subprocess against a
|
||||||
|
// stand-in sftp that blocks, with a temporary directory of the test's
|
||||||
|
// own, waits until sftp is running, sends the tool the signal, and
|
||||||
|
// requires the working directory the tool made there to be gone once
|
||||||
|
// the tool has ended.
|
||||||
|
func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
temporary := t.TempDir()
|
||||||
|
started := filepath.Join(t.TempDir(), "started")
|
||||||
|
t.Setenv("KEYFUNC_TEST_STARTED", started)
|
||||||
|
standIn(t, "sftp", stalled)
|
||||||
|
|
||||||
|
//nolint:gosec // the binary is this test's own, re-run as the tool
|
||||||
|
command := exec.CommandContext(
|
||||||
|
t.Context(), os.Args[0], subcommand, installing, host,
|
||||||
|
)
|
||||||
|
|
||||||
|
command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary)
|
||||||
|
require.NoError(t, command.Start())
|
||||||
|
|
||||||
|
// sftp is started only once the working directory has been made.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
_, err := os.Stat(started)
|
||||||
|
|
||||||
|
return err == nil
|
||||||
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
working, err := os.ReadDir(temporary)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, working, 1, name)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(signal))
|
||||||
|
waitForTool(t, name, command)
|
||||||
|
|
||||||
|
left, err := os.ReadDir(temporary)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, left, name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
||||||
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ func ask() (string, error) {
|
|||||||
return checked(string(typed))
|
return checked(string(typed))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checked drops the surrounding whitespace and refuses a mnemonic that
|
// checked joins the words with single spaces, whatever whitespace
|
||||||
// does not pass the BIP-39 checksum.
|
// separated them, since the seed is computed over the string itself,
|
||||||
|
// and refuses a mnemonic that does not pass the BIP-39 checksum.
|
||||||
func checked(words string) (string, error) {
|
func checked(words string) (string, error) {
|
||||||
words = strings.TrimSpace(words)
|
words = strings.Join(strings.Fields(words), " ")
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(words) {
|
if !bip39.IsMnemonicValid(words) {
|
||||||
return "", ErrChecksum
|
return "", ErrChecksum
|
||||||
|
|||||||
Reference in New Issue
Block a user