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