1 Commits
Author SHA1 Message Date
sneak ca1faa5551 Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Successful in 2m35s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the
ssh and sftp children acted on them: the mnemonic prompt waited for
Enter, and an interrupted `age encrypt -o` went on to put the
encryption of the cut-off input in place. Now only `ssh to` and
`ssh install` catch them, from once the mnemonic is read until their
cleanup has run; everywhere else they end the tool at once. Tests cover
an interrupted `age encrypt -o` and the install working directory on a
signal.

Model: opus-5-5
2026-10-04 04:40:57 +00:00
9 changed files with 96 additions and 242 deletions
+30 -44
View File
@@ -106,8 +106,9 @@ 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'`. If the command exits with a non-zero `--mnemonic-command 'secret get foo'`. Whitespace around the output is
status, the tool prints its standard error and exits with status 1. dropped. If the command exits with a non-zero status, the tool prints its
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.
@@ -115,9 +116,7 @@ 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. Keys are derived from the mnemonic's words refused with a message saying so.
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`
@@ -163,23 +162,21 @@ 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`, then `~/.ssh/.`, and then fetches The first connection lists `~/.ssh` and then fetches `~/.ssh/authorized_keys`
`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp` from it. The file reads as empty in two cases only: `sftp` reported `~/.ssh`
reported `~/.ssh` itself as not being there, or both listings came up and the itself as not being there, or the listing came up and the file was not in it.
file was not found. Any other outcome of that connection fails the run — a Any other outcome of that connection fails the run — a `~/.ssh` that is there
`~/.ssh` that is there but cannot be read or entered, an `authorized_keys` that but cannot be entered, an `authorized_keys` that is there but cannot be read, or
is there but cannot be read, or a connection that did not come up — and the tool a connection that did not come up — and the tool prints what `sftp` said and
prints what `sftp` said and exits with status 1 without writing anything, rather exits with status 1 without writing anything, rather than put a file back
than put a file back holding the new key alone. The listings are what tell a holding the new key alone. The listing is what tells a missing directory from
missing directory from one shut to the user, which `sftp` reports on a fetch the one shut to the user, which `sftp` reports on a fetch the same way; the wording
same way: one that cannot be read fails the first listing, and one that can be of a missing file elsewhere does not count either, since `ssh` writes
read but not entered fails the second, after which the tool says that `~/.ssh` `No such file or directory` about an `-i` it cannot find on a session that then
cannot be entered. The wording of a missing file elsewhere does not count authenticates through the agent. If an identical line is already in the file,
either, since `ssh` writes `No such file or directory` about an `-i` it cannot the tool prints `already present` and connects no further. Otherwise the line is
find on a session that then authenticates through the agent. If an identical added (after a newline, if the file did not end with one) and a second
line is already in the file, the tool prints `already present` and connects no connection:
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;
@@ -202,10 +199,7 @@ 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. Nor does it ask whether to trust a host key has to do it, not a typed password.
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...]`
@@ -266,18 +260,10 @@ 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, mnemonic Test vector: the child-mnemonic step is checked against BIP-85's own published
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: vectors, which derive from the specification's master key
`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
@@ -290,12 +276,12 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, 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). 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 An interrupted `age encrypt -o` or `age decrypt -o` leaves the named file as it
the unfinished file it was writing, leaves a file already at the named path as was; the unfinished file it was writing stays beside it, named
it was, and exits with status 1. While `ssh to` or `ssh install` has `ssh` or `<file>.<digits>`. While `ssh to` or `ssh install` has `ssh` or `sftp` running,
`sftp` running, the signal ends that program instead, the tool removes its agent the signal ends that program instead, the tool removes its agent socket or
socket or working files, and it exits with status 1, or for `ssh to` with working files, and it exits with status 1, or for `ssh to` with `ssh`'s own
`ssh`'s own status if `ssh` reported one. status if `ssh` reported one.
## Entrypoints ## Entrypoints
+1 -25
View File
@@ -6,9 +6,7 @@ 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"
@@ -187,38 +185,16 @@ 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 {
finished := finish(file, name, failed) return finish(file, name, failed)
signal.Stop(signals)
close(signals)
return finished
}, nil }, nil
} }
+16 -36
View File
@@ -94,49 +94,32 @@ 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) { func TestASignalStopsAnEncryptionAndPutsNoFileInPlace(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
for _, ending := range []os.Signal{ for _, ending := range []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
} { } {
interrupted(t, ending, "encrypt", "the start of the secret\n") encryptionInterrupted(t, ending.String(), ending)
} }
} }
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) { // encryptionInterrupted runs "age encrypt -o" as a subprocess reading
t.Setenv(mnemonic.Variable, example()) // from a pipe that stays open, waits until the tool has begun writing
// the file beside the one it was named, and sends it the signal. The
// All of an encryption but its last byte, so the tool reads the // tool has to end on the signal alone, with a failure, and leave
// header and then waits for the rest. // nothing at the name it was given. A tool that went on reading would
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) // not end until the input did, and would then put the encryption of the
cut := sealed[:len(sealed)-1] // cut-off input in place.
func encryptionInterrupted(t *testing.T, name string, signal os.Signal) {
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() t.Helper()
name := operation + " " + signal.String()
directory := t.TempDir() directory := t.TempDir()
sealed := filepath.Join(directory, "notes.age")
//nolint:gosec // the binary is this test's own, re-run as the tool //nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext( command := exec.CommandContext(
t.Context(), os.Args[0], "age", operation, t.Context(), os.Args[0], "age", "encrypt", "-o", sealed,
"-o", filepath.Join(directory, "notes"),
) )
command.Env = append(os.Environ(), runAsTool+"=1") command.Env = append(os.Environ(), runAsTool+"=1")
@@ -145,11 +128,11 @@ func interrupted(t *testing.T, signal os.Signal, operation, input string) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, command.Start()) require.NoError(t, command.Start())
_, err = io.WriteString(producer, input) _, err = io.WriteString(producer, "the start of the secret\n")
require.NoError(t, err) require.NoError(t, err)
// The file beside the named one is made once the mnemonic has been // The file beside the named one is made once the mnemonic has been
// read, before any input is. // read, just before the encryption starts.
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
entries, err := os.ReadDir(directory) entries, err := os.ReadDir(directory)
@@ -159,11 +142,8 @@ func interrupted(t *testing.T, signal os.Signal, operation, input string) {
require.NoError(t, command.Process.Signal(signal)) require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command) waitForTool(t, name, command)
require.Equal(t, 1, command.ProcessState.ExitCode(), name) require.False(t, command.ProcessState.Success(), name)
require.NoFileExists(t, sealed, 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
+3 -5
View File
@@ -69,11 +69,9 @@ func Root() *cobra.Command {
// //
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go // SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
// program, so a command waiting at the mnemonic prompt or reading what // program, so a command waiting at the mnemonic prompt or reading what
// it encrypts or decrypts goes no further. The exceptions catch the // it encrypts or decrypts goes no further. The exception is "ssh to"
// signals to clean up first: "ssh to" and "ssh install" while they // and "ssh install" while they have ssh or sftp running: they catch the
// have ssh or sftp running, so the child ends and their own cleanup // signals there, so the child ends and their own cleanup still runs.
// 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 {
err := Root().Execute() err := Root().Execute()
if err == nil { if err == nil {
-27
View File
@@ -22,11 +22,6 @@ 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
@@ -50,28 +45,6 @@ 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) {
+21 -37
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -35,12 +34,6 @@ 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{
@@ -219,39 +212,30 @@ 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, then .ssh/., and then gets the // there. The one session lists .ssh and then gets the file, so the
// file, so the listings settle the state of the directory before the // listing settles the state of the directory before the get is read.
// 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 both listings succeeded and the get then reported // as not there, or the listing succeeded and the get then reported the
// the file as not there. Anything else — a listing refused, the file // file as not there. Anything else — the 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 listings do: a // file from one in a directory it cannot enter, so the listing does:
// directory that is there but cannot be read fails the first, and one // a directory that is there but cannot be read is a failure, not an
// that can be read but not entered fails the second, because nothing in // empty file.
// 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 listingNotFound(said, directory) { if directoryAbsent(said) {
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
} }
@@ -268,18 +252,18 @@ func fetch(
return string(content), true, nil return string(content), true, nil
} }
// listingNotFound says whether sftp reported the path it was asked to // directoryAbsent says whether sftp reported .ssh itself as not being
// list as not being there. For .ssh that is the one listing failure // there, which is the one listing failure read as a host that has no
// read as a host that has no authorized_keys yet; for .ssh/., once .ssh // authorized_keys yet. The reading is taken only from the line in which
// itself has been listed, it is a .ssh that is there but cannot be // sftp reports on that directory: any other failure of the listing, in
// entered. The reading is taken only from the line in which sftp // particular a directory that is there but cannot be entered, is left
// reports on that path: any other failure of a listing, in particular a // as a failure, so that no key is written to a host whose keys were
// directory that is there but cannot be read, is left as a failure, so // never read.
// that no key is written to a host whose keys were never read. func directoryAbsent(said string) bool {
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 == path || strings.HasSuffix(named, "/"+path)) { if is && (named == directory ||
strings.HasSuffix(named, "/"+directory)) {
return true return true
} }
} }
@@ -288,9 +272,9 @@ func listingNotFound(said, path 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 it, and whether the line is such a report. The // list for want of the directory, and whether the line is such a
// client writes this one wording when it cannot look up the path a // report. The client writes this one wording when the directory a
// listing names, giving the path the server expanded. // listing names is not there, 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: "`
+4 -12
View File
@@ -80,11 +80,8 @@ 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 read // directory it cannot find is reported one way, and one it cannot enter
// another, and only the first is read as a host with no .ssh yet. A // another, and only the first is read as a host with no .ssh yet.
// .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()
@@ -105,16 +102,11 @@ 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 read": { "the directory is there and cannot be entered": {
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,
@@ -130,7 +122,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 listingNotFound(listing.said, directory) != listing.want { if directoryAbsent(listing.said) != 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,
+18 -52
View File
@@ -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 .ssh that is listed but cannot be entered. // to make a step of the write session fail.
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,11 +106,9 @@ 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, and one the user can enter but not write // turn on the user it runs as. An -i naming a file that is not here
// to by mode 500, which the put reads off the same way. An -i naming a // draws the warning ssh writes for it, which carries the wording of a
// file that is not here draws the warning ssh writes for it, which // missing file into a session that goes on to authenticate.
// 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=
@@ -162,13 +160,7 @@ 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) put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
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 ;;
@@ -347,29 +339,6 @@ 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())
@@ -433,30 +402,27 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
pretend := pretendHost(t) pretend := pretendHost(t)
// A .ssh that can be listed and entered but not written to: the // A file where the .ssh directory belongs: the listing shows it and
// fetch finds no file in it, and then the put has nowhere to put // so the directory reads as already there, but then the put has
// anything, so the write session ends at the put. // nowhere to put anything, so the write session ends at the put.
const unwritable = 0o500 inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t,
directory := filepath.Join(pretend.home, keptUnder) os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
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, after the three commands of the fetch, is the first and // The put is the first and last command the write session got to,
// last command the write session got to, and the file it was // and the file it was uploading is the one the message names.
// uploading is the one the message names.
sent := recorded(t, pretend.batch) sent := recorded(t, pretend.batch)
require.Len(t, sent, 4) require.Len(t, sent, 3)
require.Equal(t, "put", strings.Fields(sent[3])[0]) require.Equal(t, "put", strings.Fields(sent[2])[0])
require.Contains(t, err.Error(), strings.Fields(sent[3])[2]) require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
left, err := os.ReadDir(directory) require.Equal(t, notADirectory, read(t, inTheWay))
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
+3 -4
View File
@@ -104,11 +104,10 @@ func ask() (string, error) {
return checked(string(typed)) return checked(string(typed))
} }
// checked joins the words with single spaces, whatever whitespace // checked drops the surrounding whitespace and refuses a mnemonic that
// separated them, since the seed is computed over the string itself, // does not pass the BIP-39 checksum.
// 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.Join(strings.Fields(words), " ") words = strings.TrimSpace(words)
if !bip39.IsMnemonicValid(words) { if !bip39.IsMnemonicValid(words) {
return "", ErrChecksum return "", ErrChecksum