Files
keyfunc/internal/cli/ssh_test.go
T
clawbot 11a6050ac7
check / check (push) Failing after 2s
ssh install refuses stray arguments before -- and a symlinked authorized_keys (closes #61)
Anything but the host before --, or a second argument when there is no --, used to reach sftp in front of the host; it is now refused before the mnemonic is read or any connection is made. That includes "install -- host", which names no host before --.

The first listing of ~/.ssh is now a long one, ls -n, which the client formats itself whatever the server, so an authorized_keys that is a symlink shows as one. It is refused before any upload, since the rename would have replaced the link and left the file it points at without the key. The README says so.

Model: opus-5-5
2026-10-04 15:58:13 +00:00

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")
}