Files
keyfunc/internal/cli/ssh_test.go
clawbot 8aeed7b901
All checks were successful
check / check (push) Successful in 4s
ssh install works over sftp and runs nothing on the host (closes #10)
ssh install no longer runs a command on the host. It reads .ssh/authorized_keys over sftp, takes the empty reading only from sftp's own message about that path, appends the derived key locally when it is not already present, uploads the result beside the file with mode 0600 and renames it over the original. Any other failure prints what sftp said, writes nothing and exits 1. sftp batch mode disables password prompts, so a key or agent is required; a directory the owner cannot enter reads as a host with no file, which README.md states.

Model: opus-5 (implementation); fable-5-1 (landing)
2026-09-08 08:20:17 +02:00

511 lines
15 KiB
Go

package cli_test
import (
"bytes"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"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"
)
// 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"
)
// 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"
// 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, 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 two ways a get can fail are worded as the OpenSSH client words
// them, both naming the path the server expanded: a file that is not
// there, which is the one failure the tool reads as an empty file, and
// a file that is there and cannot be read, which is not. 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 = `
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
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, and ends with the
// status the test asked for.
const caller = `
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"
`
// 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 fetch and nothing after it: the tool did not connect again.
require.Len(t, recorded(t, pretend.batch), 1)
}
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-"),
)
require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
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 fetch and nothing after it, and what was on the host is
// still what is on the host.
require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable)
}
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.Len(t, recorded(t, pretend.batch), 1)
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: nothing is there to
// fetch, and 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 last command the 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])
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, "uptime")
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, "uptime"}, 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, "uptime"}
require.Equal(t, failingStatus, cli.Main())
}
// 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
}
// 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"),
)
}
// 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")
}