Public Access
SSH client: a terminal on another machine (#2)
The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0) on mbedTLS. A server is trusted the first time on its fingerprint, and a changed key is a warning with Cancel selected. The password is typed each time and kept nowhere; or the device makes itself an Ed25519 key, whose public half is shown, written to /ssh/id_ed25519.pub and printed by `ssh status`. lib/term is the terminal: what a shell, less, top, nano and vim send, with sixteen colours, scroll regions, the alternate screen and 100 lines of scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told to the far end. The session goes on when the App is left; SSH shows in the Status Bar. `ssh user@host` in the Shell opens the App. Also: - Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work from the real keyboard. - IRC doesn't try to connect under 60 KB free: started with a session open, its TLS handshake took the heap down to 236 bytes. - libssh's own curve25519 is left out of the build (scripts/libssh_filter.py): libsodium's has the same names. Costs 292 KB of flash and about 50 KB of heap while a session is open; not started under 75 KB free. Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ssh_hosts.h"
|
||||
#include "terminal.h"
|
||||
|
||||
using namespace roro::term;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
void feed(Terminal& t, const std::string& s) { t.feed(reinterpret_cast<const uint8_t*>(s.data()), s.size()); }
|
||||
#define ROW(t, r, text) TEST_ASSERT_EQUAL_STRING(text, (t).rowText(r).c_str())
|
||||
} // namespace
|
||||
|
||||
void test_text_lines_and_the_cursor() {
|
||||
Terminal t(20, 5, 10);
|
||||
feed(t, "hello\r\nworld");
|
||||
ROW(t, 0, "hello");
|
||||
ROW(t, 1, "world");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(5, t.cursorCol());
|
||||
feed(t, "\b\b!"); // backspace moves, it doesn't erase
|
||||
ROW(t, 1, "wor!d");
|
||||
feed(t, "\rW\tx"); // a tab goes to the next eighth column
|
||||
ROW(t, 1, "Wor!d x");
|
||||
feed(t, "\a");
|
||||
TEST_ASSERT_TRUE(t.takeBell());
|
||||
TEST_ASSERT_FALSE(t.takeBell());
|
||||
}
|
||||
|
||||
void test_wrapping_and_scrolling_into_history() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "0123456789"); // exactly a line: the cursor waits at the edge
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
feed(t, "ab");
|
||||
ROW(t, 0, "0123456789");
|
||||
ROW(t, 1, "ab");
|
||||
feed(t, "\r\nc\r\nd\r\ne"); // five lines through three rows
|
||||
ROW(t, 0, "c");
|
||||
ROW(t, 2, "e");
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("0123456789", t.historyLine(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ab", t.historyLine(1).c_str());
|
||||
for (int i = 0; i < 20; i++) feed(t, "\r\nx");
|
||||
TEST_ASSERT_EQUAL_INT(5, t.historyCount()); // no more than it was given room for
|
||||
}
|
||||
|
||||
void test_moving_and_erasing() {
|
||||
Terminal t(20, 5, 0);
|
||||
feed(t, "aaaaaaaaaa\r\nbbbbbbbbbb\r\ncccccccccc");
|
||||
feed(t, "\x1b[2;4H"); // row 2, column 4
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
|
||||
feed(t, "\x1b[K"); // to the end of the line
|
||||
ROW(t, 1, "bbb");
|
||||
feed(t, "\x1b[A\x1b[1K"); // up one, erase to the start (the cursor's place included)
|
||||
ROW(t, 0, " aaaaaa");
|
||||
feed(t, "\x1b[3;1H\x1b[2K");
|
||||
ROW(t, 2, "");
|
||||
feed(t, "\x1b[1;1Hxyz\x1b[J"); // from the cursor to the end of the screen
|
||||
ROW(t, 0, "xyz");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b[5;18Hend\x1b[99;99H"); // far outside: the last cell
|
||||
TEST_ASSERT_EQUAL_INT(4, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(19, t.cursorCol());
|
||||
feed(t, "\x1b[2J");
|
||||
for (int r = 0; r < 5; r++) ROW(t, r, "");
|
||||
feed(t, "\x1b[Habcdef\x1b[1;3H\x1b[2P"); // two characters deleted in the middle
|
||||
ROW(t, 0, "abef");
|
||||
feed(t, "\x1b[2@"); // and two blanks put in
|
||||
ROW(t, 0, "ab ef");
|
||||
feed(t, "\x1b[1;1H\x1b[3X");
|
||||
ROW(t, 0, " ef");
|
||||
feed(t, "\x1b[4G!\x1b[2d?"); // column 4, then row 2 in the column after
|
||||
ROW(t, 0, " !ef");
|
||||
ROW(t, 1, " ?");
|
||||
}
|
||||
|
||||
void test_colours() {
|
||||
Terminal t(20, 3, 0);
|
||||
feed(t, "a\x1b[31mb\x1b[1mc\x1b[0;44md\x1b[7me\x1b[0m\x1b[38;5;196mf\x1b[48;2;0;0;255mg\x1b[92mh");
|
||||
TEST_ASSERT_EQUAL_HEX8(0x07, t.cell(0, 0).attr);
|
||||
TEST_ASSERT_EQUAL_HEX8(0x01, t.cell(0, 1).attr); // red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 2).attr); // bold red is bright red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x47, t.cell(0, 3).attr); // on blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0x74, t.cell(0, 4).attr); // inverse: the two swapped
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 5).attr); // one of 256, as the nearest of sixteen
|
||||
TEST_ASSERT_EQUAL_HEX8(0xC9, t.cell(0, 6).attr); // a colour by its red, green and blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCA, t.cell(0, 7).attr); // bright green, written directly
|
||||
feed(t, "\x1b[0m\x1b[42m\x1b[2;1H\x1b[K"); // erasing paints with the colour behind
|
||||
TEST_ASSERT_EQUAL_HEX8(0x27, t.cell(1, 5).attr);
|
||||
uint8_t r, g, b;
|
||||
colourRgb(15, r, g, b);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, r);
|
||||
}
|
||||
|
||||
void test_scroll_regions_as_less_and_vim_use_them() {
|
||||
Terminal t(10, 5, 5);
|
||||
feed(t, "1\r\n2\r\n3\r\n4\r\n5");
|
||||
feed(t, "\x1b[2;4r"); // rows 2 to 4 scroll; the cursor goes home
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
feed(t, "\x1b[4;1H\n"); // a line feed at the bottom of the region
|
||||
ROW(t, 0, "1");
|
||||
ROW(t, 1, "3");
|
||||
ROW(t, 2, "4");
|
||||
ROW(t, 3, "");
|
||||
ROW(t, 4, "5");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount()); // a region that isn't the whole screen keeps nothing
|
||||
feed(t, "\x1b[2;1H\x1bM"); // reverse index at its top: it scrolls down
|
||||
ROW(t, 1, "");
|
||||
ROW(t, 2, "3");
|
||||
ROW(t, 3, "4");
|
||||
feed(t, "\x1b[3;1H\x1b[L"); // a line put in
|
||||
ROW(t, 2, "");
|
||||
ROW(t, 3, "3");
|
||||
feed(t, "\x1b[M"); // and taken out
|
||||
ROW(t, 2, "3");
|
||||
feed(t, "\x1b[r\x1b[1;1H\x1b[M"); // the whole screen again; a deleted first line isn't history
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
}
|
||||
|
||||
void test_the_alternate_screen() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "shell$ vi");
|
||||
feed(t, "\x1b[?1049h");
|
||||
TEST_ASSERT_TRUE(t.altScreen());
|
||||
ROW(t, 0, "");
|
||||
feed(t, "~\r\n~\r\n~\r\n~"); // scrolling here is nobody's history
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
feed(t, "\x1b[?1049l");
|
||||
TEST_ASSERT_FALSE(t.altScreen());
|
||||
ROW(t, 0, "shell$ vi"); // as it was left
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
}
|
||||
|
||||
void test_modes_and_what_is_asked_back() {
|
||||
Terminal t(10, 3, 0);
|
||||
std::string said;
|
||||
t.reply = [&](const std::string& s) { said += s; };
|
||||
feed(t, "\x1b[?25l");
|
||||
TEST_ASSERT_FALSE(t.cursorVisible());
|
||||
feed(t, "\x1b[?25h\x1b[?1h");
|
||||
TEST_ASSERT_TRUE(t.cursorVisible());
|
||||
TEST_ASSERT_TRUE(t.appCursorKeys());
|
||||
feed(t, "\x1b[2;5H\x1b[6n");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[2;5R", said.c_str());
|
||||
said.clear();
|
||||
feed(t, "\x1b[c");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[?6c", said.c_str());
|
||||
feed(t, "\x1b[?7l\x1b[1;1H0123456789abc"); // no wrapping: the last column is overwritten
|
||||
ROW(t, 0, "012345678c");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b" "c"); // a full reset
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_FALSE(t.appCursorKeys());
|
||||
}
|
||||
|
||||
void test_what_is_skipped_and_what_is_replaced() {
|
||||
Terminal t(30, 3, 0);
|
||||
feed(t, "\x1b]0;a window title\x07" "A"); // a title: not shown
|
||||
feed(t, "\x1b]2;another\x1b\\" "B"); // ended the other way
|
||||
feed(t, "\x1b[?2004h\x1b[>4;2m\x1b=" "C"); // modes this has no use for
|
||||
ROW(t, 0, "ABC");
|
||||
feed(t, "\r\n\xC3\xA9t\xC3\xA9 \xE2\x82\xAC \xE2\x94\x8C\xE2\x94\x80\xE2\x94\x90 \xE2\x94\x82 \xF0\x9F\x98\x80"); // été € ┌─┐ │ 😀
|
||||
ROW(t, 1, "\xE9t\xE9 ? +-+ | ?");
|
||||
feed(t, "\r\n\x1b(0lqk\x1b(B lqk"); // the old way of drawing lines, then letters again
|
||||
ROW(t, 2, "+-+ lqk");
|
||||
feed(t, "\x1b[2J\x1b[H\xC3("); // a character cut short doesn't swallow what follows
|
||||
ROW(t, 0, "(");
|
||||
// Nonsense must not get it stuck or out of its grid.
|
||||
std::string noise;
|
||||
for (int i = 0; i < 4000; i++) noise += static_cast<char>((i * 73 + i / 7) & 0xFF);
|
||||
feed(t, noise);
|
||||
feed(t, "\x1b" "c" "ok");
|
||||
ROW(t, 0, "ok");
|
||||
}
|
||||
|
||||
void test_a_new_size() {
|
||||
Terminal t(10, 4, 10);
|
||||
feed(t, "one\r\ntwo\r\nthree\r\nfour");
|
||||
t.resize(20, 2); // fewer rows: the cursor's line stays, the top goes to the history
|
||||
ROW(t, 0, "three");
|
||||
ROW(t, 1, "four");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("two", t.historyLine(1).c_str());
|
||||
t.resize(4, 6); // narrower: what doesn't fit is cut
|
||||
ROW(t, 0, "thre");
|
||||
feed(t, "\r\n123456");
|
||||
ROW(t, 2, "1234");
|
||||
ROW(t, 3, "56");
|
||||
}
|
||||
|
||||
void test_keys() {
|
||||
TEST_ASSERT_EQUAL_STRING("a", encodeKey(TermKey::Char, 'a', false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'c', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'C', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Char, '[', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(1, encodeKey(TermKey::Char, ' ', true, false, false).size()); // Ctrl+Space is a zero
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b" "b", encodeKey(TermKey::Char, 'b', false, true, false).c_str()); // Alt is Esc first
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", encodeKey(TermKey::Char, 0xE9, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[A", encodeKey(TermKey::Up, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1bOA", encodeKey(TermKey::Up, 0, false, false, true).c_str()); // as vim asks for them
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[D", encodeKey(TermKey::Left, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\r", encodeKey(TermKey::Enter, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x7f", encodeKey(TermKey::Backspace, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Escape, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[5~", encodeKey(TermKey::PageUp, 0, false, false, false).c_str());
|
||||
}
|
||||
|
||||
void test_who_to_connect_to() {
|
||||
SshTarget t;
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("pi@raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("pi", t.user.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local", t.host.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(22, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("pi@raspberrypi.local", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local:22", t.hostPort().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("root@10.9.0.1:2222", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("root@10.9.0.1:2222", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("@host", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:0", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:99999", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@b c", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port); // a refused line changes nothing
|
||||
}
|
||||
|
||||
void test_saved_hosts_and_the_servers_met() {
|
||||
SshHosts hosts;
|
||||
SshTarget a, b;
|
||||
parseSshTarget("pi@one", a);
|
||||
parseSshTarget("root@two:2222", b);
|
||||
hosts.used(a);
|
||||
hosts.used(b);
|
||||
hosts.used(a); // again: to the front, not twice
|
||||
TEST_ASSERT_EQUAL_size_t(2, hosts.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("pi@one", hosts.list()[0].c_str());
|
||||
SshHosts again(hosts.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("root@two:2222", again.list()[1].c_str());
|
||||
for (int i = 0; i < 12; i++) {
|
||||
SshTarget t;
|
||||
parseSshTarget("u@h" + std::to_string(i), t);
|
||||
again.used(t);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(8, again.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("u@h11", again.list()[0].c_str());
|
||||
again.remove(0);
|
||||
TEST_ASSERT_EQUAL_STRING("u@h10", again.list()[0].c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(0, SshHosts("garbage\n\n@@\n").list().size());
|
||||
|
||||
SshKnownHosts known;
|
||||
TEST_ASSERT_EQUAL_STRING("", known.fingerprintOf("one:22").c_str());
|
||||
known.remember("one:22", "SHA256:aaaa");
|
||||
known.remember("two:2222", "SHA256:bbbb");
|
||||
known.remember("one:22", "SHA256:cccc"); // it changed, and the user said yes
|
||||
SshKnownHosts stored(known.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:cccc", stored.fingerprintOf("one:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:bbbb", stored.fingerprintOf("two:2222").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:2222").c_str()); // another port is another server
|
||||
for (int i = 0; i < 20; i++) stored.remember("h" + std::to_string(i) + ":22", "SHA256:x");
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:22").c_str()); // sixteen kept, the oldest gone
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h19:22").c_str());
|
||||
stored.forget("h19:22"); // forgotten with its host: met again, it is asked about again
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("h19:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h18:22").c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_text_lines_and_the_cursor);
|
||||
RUN_TEST(test_wrapping_and_scrolling_into_history);
|
||||
RUN_TEST(test_moving_and_erasing);
|
||||
RUN_TEST(test_colours);
|
||||
RUN_TEST(test_scroll_regions_as_less_and_vim_use_them);
|
||||
RUN_TEST(test_the_alternate_screen);
|
||||
RUN_TEST(test_modes_and_what_is_asked_back);
|
||||
RUN_TEST(test_what_is_skipped_and_what_is_replaced);
|
||||
RUN_TEST(test_a_new_size);
|
||||
RUN_TEST(test_keys);
|
||||
RUN_TEST(test_who_to_connect_to);
|
||||
RUN_TEST(test_saved_hosts_and_the_servers_met);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user