check / check (push) Failing after 3s
ssh install now takes the host alone before --: any other word there, or a second argument without --, is refused before the mnemonic is read or sftp runs, so keyfunc ssh install alice@host frank@host no longer installs the key for frank@host. The first listing of ~/.ssh is now ls -n, which shows the file type, so a symlinked authorized_keys is refused before any upload instead of being replaced by a regular file; the README says so. Judgement calls: install -- host is refused; a symlinked authorized_keys is refused even when its target already holds the key. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
975 lines
29 KiB
Go
975 lines
29 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/keyfunc/internal/cli"
|
|
"sneak.berlin/go/keyfunc/internal/cli/ssh"
|
|
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
|
)
|
|
|
|
// 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
|
|
// signal tests can drive the real signal path in a process they can
|
|
// send a signal to.
|
|
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
|
|
|
// TestMain re-executes the test binary as the tool when runAsTool is
|
|
// set, and otherwise runs the suite. The signal tests start the tool
|
|
// this way, as a subprocess they can signal and watch end.
|
|
func TestMain(m *testing.M) {
|
|
if os.Getenv(runAsTool) == "1" {
|
|
os.Exit(cli.Main())
|
|
}
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// The modes the host is supposed to end up with, and the mode the
|
|
// stand-ins need so that they can be run at all.
|
|
const (
|
|
directoryMode = 0o700
|
|
fileMode = 0o600
|
|
standInMode = 0o755
|
|
)
|
|
|
|
// failingStatus is the status the stand-in ssh ends with when a test
|
|
// wants to see a status handed on, and failedStatus is the status the
|
|
// tool itself ends with when something went wrong.
|
|
const (
|
|
failingStatus = 7
|
|
failedStatus = 1
|
|
)
|
|
|
|
// notADirectory is what a test puts where the .ssh directory belongs
|
|
// to make a .ssh that is listed but cannot be entered.
|
|
const notADirectory = "a file where the directory belongs\n"
|
|
|
|
// missingIdentity is a path with no file at it, handed to sftp after
|
|
// the dashes so that ssh warns about it in the words of a missing
|
|
// file.
|
|
const missingIdentity = "/nonexistent/keyfunc-test-identity"
|
|
|
|
// The host, and where on it the key ends up.
|
|
const (
|
|
host = "someone@example.com"
|
|
keptUnder = ".ssh"
|
|
keptIn = "authorized_keys"
|
|
)
|
|
|
|
// remoteCommand is the command the "to" tests hand ssh after the host.
|
|
const remoteCommand = "uptime"
|
|
|
|
// The tool's own name, as it stands in the arguments a test hands to
|
|
// Main, the ssh subcommand both commands the tests here drive live
|
|
// under, and the one of those two these tests name most.
|
|
const (
|
|
tool = "keyfunc"
|
|
subcommand = "ssh"
|
|
installing = "install"
|
|
)
|
|
|
|
// The key line the example mnemonic gives at index 0, as it stands in
|
|
// an authorized_keys file.
|
|
const keyLine = vectorZero + " keyfunc/ssh/0\n"
|
|
|
|
// marker is a variable set beside the mnemonic ones and expected to
|
|
// reach the stand-in, so a scrubbed environment is told apart from an
|
|
// empty one.
|
|
const marker = "KEYFUNC_TEST_MARKER"
|
|
|
|
// installer is a stand-in for the system sftp for the install
|
|
// command. It writes down the arguments and every command of the
|
|
// batch it is given, echoes each command as sftp does, writes down its
|
|
// own environment when a test asks for it, and carries the commands out
|
|
// against a directory standing in for the host's home directory, so that
|
|
// what keyfunc sends can be watched doing its work. A command that
|
|
// begins with a dash may fail; any other failure ends the session, as it
|
|
// does in sftp's own batch mode.
|
|
//
|
|
// The listing and the two ways a get can fail are worded as the
|
|
// OpenSSH client words them, each naming the path the server expanded.
|
|
// A long listing (-n) writes each entry as the client does, its type
|
|
// first, so that a symlink shows as one.
|
|
// A listing fails one way when .ssh is not there and another when it is
|
|
// there but shut to the user; the first is the only failure read as a
|
|
// host with no file. A get fails one way for a file that is not there,
|
|
// which after a listing that came up empty is also read as no file, and
|
|
// 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,
|
|
// 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
|
|
// to by mode 500, which the put reads off the same way. An -i naming a
|
|
// 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 = `
|
|
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
|
previous=
|
|
for argument in "$@"; do
|
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
|
if [ "$previous" = -i ] && [ ! -e "$argument" ]; then
|
|
printf 'Warning: Identity file %s not accessible: %s.\n' \
|
|
"$argument" "No such file or directory" >&2
|
|
fi
|
|
previous=$argument
|
|
done
|
|
home="$KEYFUNC_TEST_HOME"
|
|
while IFS= read -r line; do
|
|
printf 'sftp> %s\n' "$line"
|
|
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
|
|
allowed=no
|
|
case "$line" in
|
|
-*)
|
|
line=${line#-}
|
|
allowed=yes
|
|
;;
|
|
esac
|
|
eval "set -- $line"
|
|
worked=yes
|
|
case "$1" in
|
|
ls)
|
|
dir=$3
|
|
if [ ! -e "$home/$dir" ]; then
|
|
worked=no
|
|
printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2
|
|
elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then
|
|
worked=no
|
|
printf 'remote readdir("%s/"): Permission denied\n' \
|
|
"$home/$dir" >&2
|
|
else
|
|
for entry in "$home/$dir"/*; do
|
|
[ -e "$entry" ] || continue
|
|
name="$dir/$(basename "$entry")"
|
|
if [ "$2" = -n ]; then
|
|
printf '%s ? someone users 0 Oct 4 15:44 %s\n' \
|
|
"$(stat -c '%A' "$entry")" "$name"
|
|
else
|
|
printf '%s\n' "$name"
|
|
fi
|
|
done
|
|
fi
|
|
;;
|
|
get)
|
|
if [ ! -e "$home/$2" ]; then
|
|
worked=no
|
|
printf 'File "%s" not found.\n' "$home/$2" >&2
|
|
elif ! cp "$home/$2" "$3" 2>/dev/null; then
|
|
worked=no
|
|
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
|
|
fi
|
|
;;
|
|
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 ;;
|
|
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
|
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
|
|
esac
|
|
if [ "$worked" = no ] && [ "$allowed" = no ]; then
|
|
printf 'sftp: %s failed\n' "$1" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
`
|
|
|
|
// caller is a stand-in for the system ssh for the to command. It
|
|
// writes down the arguments it was given, notes the agent socket if
|
|
// there really is one at the path it was handed, writes down its own
|
|
// environment when a test asks for it, and ends with the status the
|
|
// test asked for.
|
|
const caller = `
|
|
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
|
for argument in "$@"; do
|
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
|
done
|
|
socket=${2#IdentityAgent=}
|
|
if [ -S "$socket" ]; then
|
|
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
|
|
fi
|
|
exit "$KEYFUNC_TEST_STATUS"
|
|
`
|
|
|
|
// sleeper is a stand-in for the system ssh that notes the agent socket
|
|
// and then blocks, so a test can cancel the context while it is running
|
|
// and watch the tool take the agent down. The wait ends on its own only
|
|
// as a backstop, well after the test has cancelled and looked.
|
|
const sleeper = `
|
|
socket=${2#IdentityAgent=}
|
|
if [ -S "$socket" ]; then
|
|
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
|
|
fi
|
|
sleep 5
|
|
`
|
|
|
|
// stalled is a stand-in for the system sftp that starts a child, notes
|
|
// it has started, and then blocks, so a test can signal the tool while
|
|
// sftp is running. The child holds the output the tool reads sftp
|
|
// through, as the ssh that sftp starts does, and is started before the
|
|
// note so that it is there when the signal ends the shell and still
|
|
// holds that output afterwards.
|
|
const stalled = `
|
|
sleep 5 &
|
|
touch "$KEYFUNC_TEST_STARTED"
|
|
wait
|
|
`
|
|
|
|
// pretended is where a stand-in writes down what it was asked to do.
|
|
type pretended struct {
|
|
// home stands in for the home directory on the host.
|
|
home string
|
|
// arguments holds the arguments of every session, one per line.
|
|
arguments string
|
|
// batch holds the commands of every session, one per line.
|
|
batch string
|
|
}
|
|
|
|
func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
|
|
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
|
require.NoError(t, err)
|
|
require.Equal(t,
|
|
os.FileMode(directoryMode), directory.Mode().Perm(),
|
|
)
|
|
|
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
|
|
|
file, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(fileMode), file.Mode().Perm())
|
|
|
|
require.Equal(t, keyLine, read(t, path))
|
|
}
|
|
|
|
func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
path := seed(t, pretend, "somebody else\n"+keyLine)
|
|
|
|
require.Equal(t, "already present\n", install(t, host))
|
|
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
|
|
|
|
// The read and nothing after it: the tool did not connect again.
|
|
require.Equal(t, 1, connections(t, pretend))
|
|
}
|
|
|
|
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
path := seed(t, pretend, "")
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
require.Equal(t, keyLine, read(t, path))
|
|
}
|
|
|
|
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
|
path := seed(t, pretend, already)
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
require.Equal(t, already+"\n"+keyLine, read(t, path))
|
|
}
|
|
|
|
func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
|
|
sent := recorded(t, pretend.batch)
|
|
require.Len(t, sent, 6)
|
|
|
|
// The name of the uploaded file is random, so it is read off the
|
|
// put and then looked for in the two commands that follow.
|
|
beside := strings.Fields(sent[3])[2]
|
|
require.True(t,
|
|
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
|
|
)
|
|
|
|
// The listing fails on a host with no .ssh, so the get never runs;
|
|
// the write session then makes the directory and puts the file.
|
|
require.Equal(t, "ls -n .ssh", sent[0])
|
|
require.Equal(t, "-mkdir .ssh", sent[1])
|
|
require.Equal(t, "chmod 700 .ssh", sent[2])
|
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
|
require.Equal(t, "chmod 600 "+beside, sent[4])
|
|
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
|
|
}
|
|
|
|
func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
unreadable := unfetchable(t, pretend)
|
|
|
|
printed, said, err := attempt(t, host)
|
|
require.Error(t, err)
|
|
require.Empty(t, printed)
|
|
require.Contains(t, said, "Permission denied")
|
|
|
|
// The read and nothing after it, and what was on the host is
|
|
// still what is on the host.
|
|
require.Equal(t, 1, connections(t, pretend))
|
|
require.DirExists(t, unreadable)
|
|
}
|
|
|
|
func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
unlistable(t, pretend)
|
|
|
|
// The listing is refused, which is not the same as no directory, so
|
|
// the tool writes nothing rather than treat a directory it cannot
|
|
// enter as a host with no file.
|
|
printed, said, err := attempt(t, host)
|
|
require.Error(t, err)
|
|
require.Empty(t, printed)
|
|
require.Contains(t, said, "Permission denied")
|
|
|
|
// The read and nothing after it: no second connection wrote a key.
|
|
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 TestASymlinkedFileIsRefusedBeforeAnyUpload(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
// The file the link points at, which the key would never reach.
|
|
target := filepath.Join(pretend.home, "keys")
|
|
require.NoError(t,
|
|
os.WriteFile(target, []byte("somebody else\n"), fileMode),
|
|
)
|
|
|
|
directory := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t, os.Mkdir(directory, directoryMode))
|
|
|
|
link := filepath.Join(directory, keptIn)
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
printed, _, err := attempt(t, host)
|
|
require.ErrorIs(t, err, ssh.ErrSymlink)
|
|
require.Empty(t, printed)
|
|
|
|
// The read and nothing after it: no upload was tried, the link
|
|
// still points where it did, and what it points at is unchanged.
|
|
require.Equal(t, 1, connections(t, pretend))
|
|
|
|
pointsAt, err := os.Readlink(link)
|
|
require.NoError(t, err)
|
|
require.Equal(t, target, pointsAt)
|
|
require.Equal(t, "somebody else\n", read(t, target))
|
|
}
|
|
|
|
func TestAnArgumentBesideTheHostIsRefusedBeforeAnyConnection(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
runs := [][]string{
|
|
{host, "frank@example.com"},
|
|
{host, "2222"},
|
|
{host, "frank@example.com", "--", "-P", "2222"},
|
|
{"--", host},
|
|
}
|
|
|
|
for _, args := range runs {
|
|
printed, _, err := attempt(t, args...)
|
|
require.ErrorIs(t, err, ssh.ErrStrayArgument)
|
|
require.Empty(t, printed)
|
|
}
|
|
|
|
// sftp was never started, so nothing was uploaded.
|
|
require.NoFileExists(t, pretend.arguments)
|
|
}
|
|
|
|
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
// A directory that is there but holds no file yet, made with a mode
|
|
// of its own so that a stray chmod would show.
|
|
const ownMode = 0o755
|
|
|
|
directory := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t, os.Mkdir(directory, ownMode))
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
|
|
// The key is added and the directory keeps the mode it had: the
|
|
// write session neither made it nor set its mode.
|
|
require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn)))
|
|
|
|
kept, err := os.Stat(directory)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm())
|
|
|
|
sent := recorded(t, pretend.batch)
|
|
require.NotContains(t, sent, "-mkdir .ssh")
|
|
require.NotContains(t, sent, "chmod 700 .ssh")
|
|
}
|
|
|
|
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
unreadable := unfetchable(t, pretend)
|
|
|
|
// ssh warns about an -i it cannot find in the words of a missing
|
|
// file, on a session that then authenticates perfectly well. That
|
|
// warning is not sftp reporting on authorized_keys, so the fetch
|
|
// failure is still a failure.
|
|
printed, said, err := attempt(t, host, "--", "-i", missingIdentity)
|
|
require.Error(t, err)
|
|
require.Empty(t, printed)
|
|
require.Contains(t, said, "No such file or directory")
|
|
require.Contains(t, said, "Permission denied")
|
|
|
|
require.Equal(t, 1, connections(t, pretend))
|
|
require.DirExists(t, unreadable)
|
|
|
|
// The same run again, this way for the status it ends with.
|
|
given := os.Args
|
|
|
|
t.Cleanup(func() { os.Args = given })
|
|
|
|
os.Args = []string{
|
|
tool, subcommand, installing, host, "--", "-i", missingIdentity,
|
|
}
|
|
|
|
require.Equal(t, failedStatus, cli.Main())
|
|
}
|
|
|
|
func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
// A .ssh that can be listed and entered but not written to: the
|
|
// fetch finds no file in it, and then the put has nowhere to put
|
|
// anything, so the write session ends at the put.
|
|
const unwritable = 0o500
|
|
|
|
directory := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t, os.Mkdir(directory, unwritable))
|
|
|
|
printed, said, err := attempt(t, host)
|
|
require.Error(t, err)
|
|
require.Empty(t, printed)
|
|
require.Contains(t, said, "put failed")
|
|
|
|
// The put, after the three commands of the fetch, is the first and
|
|
// last command the write session got to, and the file it was
|
|
// uploading is the one the message names.
|
|
sent := recorded(t, pretend.batch)
|
|
require.Len(t, sent, 4)
|
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
|
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
|
|
|
|
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.
|
|
given := os.Args
|
|
|
|
t.Cleanup(func() { os.Args = given })
|
|
|
|
os.Args = []string{tool, subcommand, installing, host}
|
|
|
|
require.Equal(t, failedStatus, cli.Main())
|
|
}
|
|
|
|
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
install(t, host)
|
|
|
|
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
|
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
|
|
}
|
|
|
|
func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
install(t, host, "--", "-P", "2222")
|
|
|
|
// The same arguments twice over: adding a line takes two
|
|
// connections, one to fetch the file and one to write it back.
|
|
session := []string{"-b", "-", "-P", "2222", host}
|
|
require.Equal(t,
|
|
slices.Concat(session, session),
|
|
recorded(t, pretend.arguments),
|
|
)
|
|
}
|
|
|
|
func TestAProcessSFTPLeavesBehindDoesNotFailTheRun(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
// A session that works ends by leaving a child behind that holds
|
|
// sftp's output, as the master ssh leaves running for ControlPersist
|
|
// does under -v.
|
|
standIn(t, "sftp", installer+"sleep 5 &\n")
|
|
|
|
require.Equal(t, "added\n", install(t, host))
|
|
require.Equal(t, keyLine,
|
|
read(t, filepath.Join(pretend.home, keptUnder, keptIn)),
|
|
)
|
|
}
|
|
|
|
func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
arguments, noted := pretendCall(t)
|
|
|
|
_, err := execute(t, subcommand, "to", host, remoteCommand)
|
|
|
|
var passed ssh.StatusError
|
|
|
|
require.ErrorAs(t, err, &passed)
|
|
require.Equal(t, failingStatus, passed.Status)
|
|
|
|
given := recorded(t, arguments)
|
|
require.Equal(t, "-o", given[0])
|
|
require.Equal(t, []string{host, remoteCommand}, given[2:])
|
|
|
|
// The stand-in wrote the path down only because there really was
|
|
// a socket there while it ran.
|
|
socket := strings.TrimSpace(read(t, noted))
|
|
require.Equal(t, "IdentityAgent="+socket, given[1])
|
|
require.NoDirExists(t, filepath.Dir(socket))
|
|
}
|
|
|
|
func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretendCall(t)
|
|
|
|
given := os.Args
|
|
|
|
t.Cleanup(func() { os.Args = given })
|
|
|
|
os.Args = []string{tool, subcommand, "to", host, remoteCommand}
|
|
|
|
require.Equal(t, failingStatus, cli.Main())
|
|
}
|
|
|
|
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
// The three signals the tool handles, checked one after another.
|
|
signals := []struct {
|
|
name string
|
|
signal os.Signal
|
|
}{
|
|
{"SIGTERM", syscall.SIGTERM},
|
|
{"SIGINT", syscall.SIGINT},
|
|
{"SIGHUP", syscall.SIGHUP},
|
|
}
|
|
|
|
for _, ending := range signals {
|
|
signalEndsTheTool(t, ending.name, ending.signal)
|
|
}
|
|
}
|
|
|
|
// signalEndsTheTool runs the tool as a subprocess against a stand-in
|
|
// 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
|
|
// and its directory to be gone once the tool has ended. The subprocess
|
|
// goes through Main and the command's signal handling, so with that handling
|
|
// removed the signal kills the tool outright, no deferred cleanup runs,
|
|
// the directory is left behind, and the check fails.
|
|
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
|
t.Helper()
|
|
|
|
noted := filepath.Join(t.TempDir(), "socket")
|
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
|
standIn(t, "ssh", sleeper)
|
|
|
|
//nolint:gosec // the binary is this test's own, re-run as the tool
|
|
command := exec.CommandContext(
|
|
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
|
|
)
|
|
|
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
|
require.NoError(t, command.Start())
|
|
|
|
// The stand-in notes the socket only once the agent is up and ssh
|
|
// is running against it, so this is where the signal lands.
|
|
socket := waitForSocket(t, noted)
|
|
|
|
require.NoError(t, command.Process.Signal(signal))
|
|
waitForTool(t, name, command)
|
|
|
|
// The signal ended the tool, and its deferred cleanup still ran:
|
|
// the agent socket and its directory are gone.
|
|
require.NoDirExists(t, filepath.Dir(socket), name)
|
|
}
|
|
|
|
// waitForTool waits for the subprocess to end, and fails the test if it
|
|
// does not end in time.
|
|
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
|
|
t.Helper()
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- command.Wait() }()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("the tool did not end after %s", name)
|
|
}
|
|
}
|
|
|
|
// waitForSocket waits for the stand-in to write down the agent socket
|
|
// and gives back the path, which means the agent is up and ssh is
|
|
// running against it.
|
|
func waitForSocket(t *testing.T, noted string) string {
|
|
t.Helper()
|
|
|
|
var socket string
|
|
|
|
require.Eventually(t, func() bool {
|
|
content, err := os.ReadFile(noted) //nolint:gosec // test path
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
socket = strings.TrimSpace(string(content))
|
|
|
|
return socket != ""
|
|
}, 5*time.Second, 5*time.Millisecond)
|
|
|
|
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 tool to end within a second with status 1 and the
|
|
// working directory it made there to be gone.
|
|
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)
|
|
|
|
sent := time.Now()
|
|
|
|
require.NoError(t, command.Process.Signal(signal))
|
|
waitForTool(t, name, command)
|
|
|
|
// The child sftp started would hold sftp's output for seconds yet.
|
|
require.Less(t, time.Since(sent), time.Second, name)
|
|
require.Equal(t, failedStatus, command.ProcessState.ExitCode(), name)
|
|
|
|
left, err := os.ReadDir(temporary)
|
|
require.NoError(t, err)
|
|
require.Empty(t, left, name)
|
|
}
|
|
|
|
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
|
t.Setenv(mnemonic.Variable, example())
|
|
t.Setenv(marker, "reaches the stand-in")
|
|
|
|
pretendHost(t)
|
|
environment := recordEnvironment(t)
|
|
|
|
install(t, host)
|
|
|
|
mnemonicWithheld(t, read(t, environment))
|
|
}
|
|
|
|
func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
|
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
|
t.Setenv(mnemonic.Variable, example())
|
|
t.Setenv(marker, "reaches the stand-in")
|
|
|
|
pretendCall(t)
|
|
environment := recordEnvironment(t)
|
|
|
|
_, err := execute(t, subcommand, "to", host, remoteCommand)
|
|
|
|
var passed ssh.StatusError
|
|
|
|
require.ErrorAs(t, err, &passed)
|
|
|
|
mnemonicWithheld(t, read(t, environment))
|
|
}
|
|
|
|
// pretendHost puts the install stand-in on the path and gives back the
|
|
// places it writes to.
|
|
func pretendHost(t *testing.T) pretended {
|
|
t.Helper()
|
|
|
|
pretend := pretended{
|
|
home: t.TempDir(),
|
|
arguments: filepath.Join(t.TempDir(), "arguments"),
|
|
batch: filepath.Join(t.TempDir(), "batch"),
|
|
}
|
|
|
|
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
|
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
|
|
t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch)
|
|
standIn(t, "sftp", installer)
|
|
|
|
return pretend
|
|
}
|
|
|
|
// install runs the install command, requires it to have worked, and
|
|
// gives back what the tool itself printed.
|
|
func install(t *testing.T, args ...string) string {
|
|
t.Helper()
|
|
|
|
printed, _, err := attempt(t, args...)
|
|
require.NoError(t, err)
|
|
|
|
return printed
|
|
}
|
|
|
|
// attempt runs the install command with the tool's own output kept
|
|
// apart from what the stand-in said, since the stand-in echoes its
|
|
// batch as sftp does. It gives back what the tool printed, what the
|
|
// stand-in said, and how the run ended.
|
|
func attempt(t *testing.T, args ...string) (string, string, error) {
|
|
t.Helper()
|
|
|
|
var printed, said bytes.Buffer
|
|
|
|
root := cli.Root()
|
|
root.SetOut(&printed)
|
|
root.SetErr(&said)
|
|
root.SetArgs(slices.Concat([]string{subcommand, installing}, args))
|
|
|
|
err := root.ExecuteContext(t.Context())
|
|
|
|
return printed.String(), said.String(), err
|
|
}
|
|
|
|
// seed puts an authorized_keys file on the stand-in host before the
|
|
// tool runs and gives back its path.
|
|
func seed(t *testing.T, pretend pretended, content string) string {
|
|
t.Helper()
|
|
|
|
directory := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t, os.Mkdir(directory, directoryMode))
|
|
|
|
path := filepath.Join(directory, keptIn)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), fileMode))
|
|
|
|
return path
|
|
}
|
|
|
|
// unfetchable puts a directory where authorized_keys belongs on the
|
|
// stand-in host, which the stand-in can see but cannot fetch: that is
|
|
// how a file that is there and cannot be read looks from here. It
|
|
// gives back the path.
|
|
func unfetchable(t *testing.T, pretend pretended) string {
|
|
t.Helper()
|
|
|
|
require.NoError(t,
|
|
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
|
|
)
|
|
|
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
|
require.NoError(t, os.Mkdir(path, directoryMode))
|
|
|
|
return path
|
|
}
|
|
|
|
// unlistable puts a .ssh on the stand-in host that is there but shut to
|
|
// the user, a directory of mode 000, and gives back its path. Its mode
|
|
// is put back before the temporary directory is cleared so that it can
|
|
// be.
|
|
func unlistable(t *testing.T, pretend pretended) string {
|
|
t.Helper()
|
|
|
|
directory := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t, os.Mkdir(directory, directoryMode))
|
|
require.NoError(t, os.Chmod(directory, 0))
|
|
|
|
t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) })
|
|
|
|
return directory
|
|
}
|
|
|
|
// connections returns how many times the tool ran sftp, counted from
|
|
// the -b that opens each session's arguments.
|
|
func connections(t *testing.T, pretend pretended) int {
|
|
t.Helper()
|
|
|
|
count := 0
|
|
|
|
for _, argument := range recorded(t, pretend.arguments) {
|
|
if argument == "-b" {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
// pretendCall puts the to stand-in on the path and gives back the file
|
|
// the arguments are written down in and the file the agent socket is
|
|
// noted in.
|
|
func pretendCall(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
|
|
arguments := filepath.Join(t.TempDir(), "arguments")
|
|
noted := filepath.Join(t.TempDir(), "socket")
|
|
|
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments)
|
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
|
t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus))
|
|
standIn(t, "ssh", caller)
|
|
|
|
return arguments, noted
|
|
}
|
|
|
|
// standIn writes a stand-in for one of the system programs and puts it
|
|
// first on the path, so that the tool finds it instead of the real
|
|
// one.
|
|
func standIn(t *testing.T, name, body string) {
|
|
t.Helper()
|
|
|
|
directory := t.TempDir()
|
|
|
|
err := os.WriteFile(
|
|
filepath.Join(directory, name),
|
|
[]byte("#!/bin/sh\n"+body), standInMode,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Setenv("PATH",
|
|
directory+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
)
|
|
}
|
|
|
|
// recordEnvironment asks the stand-in to write its environment down and
|
|
// gives back the file it writes it to.
|
|
func recordEnvironment(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "environment")
|
|
t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path)
|
|
|
|
return path
|
|
}
|
|
|
|
// mnemonicWithheld requires that neither mnemonic variable reached the
|
|
// stand-in and that the marker set beside them did, so an empty
|
|
// environment does not pass for a scrubbed one.
|
|
func mnemonicWithheld(t *testing.T, environment string) {
|
|
t.Helper()
|
|
|
|
require.NotContains(t, environment, mnemonic.Variable+"=")
|
|
require.NotContains(t, environment, mnemonic.CommandVariable+"=")
|
|
require.Contains(t, environment, marker+"=")
|
|
}
|
|
|
|
// read returns what is in a file.
|
|
func read(t *testing.T, path string) string {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // the path is a temporary file of the test's own
|
|
content, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
|
|
return string(content)
|
|
}
|
|
|
|
// recorded returns the lines a stand-in wrote down.
|
|
func recorded(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
|
|
return strings.Split(strings.TrimSuffix(read(t, path), "\n"), "\n")
|
|
}
|