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