All checks were successful
check / check (push) Successful in 20s
The command no longer sends a shell script to the host. It fetches ~/.ssh/authorized_keys with the system sftp in batch mode, adds the key line here, and writes the file back in a second session: mkdir and chmod on ~/.ssh, put to authorized_keys.keyfunc-<random>, chmod 600, then rename over authorized_keys. A run that adds a line connects twice; one that finds the line there connects once and stops. A failed step leaves everything as it is and names the uploaded file. sftp echoes the commands it runs, so all of its output goes to standard error and the tool prints only "added" or "already present". Batch mode cannot prompt for a password; the README says so. Model: opus-5
335 lines
9.2 KiB
Go
335 lines
9.2 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"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.
|
|
const failingStatus = 7
|
|
|
|
// The host, and where on it the key ends up.
|
|
const (
|
|
host = "someone@example.com"
|
|
keptUnder = ".ssh"
|
|
keptIn = "authorized_keys"
|
|
)
|
|
|
|
// 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, 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.
|
|
const installer = `
|
|
for argument in "$@"; do
|
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
|
done
|
|
home="$KEYFUNC_TEST_HOME"
|
|
while IFS= read -r line; do
|
|
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) cp "$home/$2" "$3" 2>/dev/null || worked=no ;;
|
|
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", run(t, "ssh", "install", 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", run(t, "ssh", "install", 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", run(t, "ssh", "install", 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", run(t, "ssh", "install", 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", run(t, "ssh", "install", 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 TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
pretend := pretendHost(t)
|
|
|
|
run(t, "ssh", "install", 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)
|
|
|
|
run(t, "ssh", "install", 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, "ssh", "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{"keyfunc", "ssh", "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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
}
|