check / check (push) Successful in 39s
Main ran the command tree on a background context, so SIGINT, SIGTERM or SIGHUP killed the process before the deferred cleanup ran: the agent's temporary directory and socket, and the install working directory, were left behind. Main now runs the tree on a signal.NotifyContext for those three signals. A signal cancels the context, which ends the child ssh or sftp started with exec.CommandContext, and the deferred cleanup then runs. The exit status after a signal stays 1 unless ssh reported one of its own. For "ssh to" the child is cancelled with SIGTERM rather than the default kill, so ssh restores the terminal before it goes. Model: opus-4-8
797 lines
23 KiB
Go
797 lines
23 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
|
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
|
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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 test can drive the real signal path in a process it 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 test starts the tool
|
|
// this way, as a subprocess it can signal and watch clean up.
|
|
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 step of the write session fail.
|
|
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 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. 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=$2
|
|
[ "$dir" = -1 ] && 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
|
|
printf '%s/%s\n' "$dir" "$(basename "$entry")"
|
|
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) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
|
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
|
|
`
|
|
|
|
// 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 -1 .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 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 file where the .ssh directory belongs: the listing shows it and
|
|
// so the directory reads as already there, but then the put has
|
|
// nowhere to put anything, so the write session ends at the put.
|
|
inTheWay := filepath.Join(pretend.home, keptUnder)
|
|
require.NoError(t,
|
|
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
|
)
|
|
|
|
printed, said, err := attempt(t, host)
|
|
require.Error(t, err)
|
|
require.Empty(t, printed)
|
|
require.Contains(t, said, "put failed")
|
|
|
|
// The put 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, 3)
|
|
require.Equal(t, "put", strings.Fields(sent[2])[0])
|
|
require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
|
|
|
|
require.Equal(t, notADirectory, read(t, inTheWay))
|
|
|
|
// 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 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 its 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 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")
|
|
}
|