SSH client: a terminal on another machine (#2)
CI / build (pull_request) Successful in 2m22s
Site / build (pull_request) Successful in 10s

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:
2026-10-08 04:38:36 +02:00
co-authored by Claude Opus 5.5
parent 7223147f26
commit 6b71aace4f
47 changed files with 2801 additions and 20 deletions
+30
View File
@@ -231,6 +231,35 @@ void test_fn_h_is_help_in_both_modes() {
}
}
// Issue #2: a key that isn't a character says what was held with it. A terminal sends Page Up for
// Shift with the up arrow, and a note jumps to its start for Ctrl with it.
void test_arrows_enter_and_tab_carry_what_was_held() {
KeyMapper m;
m.setTextEntry(true);
RawKeys r = withFn({';'});
r.shift = true;
auto ev = press(m, r);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(ev[0].key));
TEST_ASSERT_TRUE(ev[0].shift);
TEST_ASSERT_FALSE(ev[0].ctrl);
KeyMapper m2;
RawKeys tab;
tab.tab = true;
tab.shift = true;
ev = press(m2, tab);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(ev[0].key));
TEST_ASSERT_TRUE(ev[0].shift);
KeyMapper m3;
m3.setTextEntry(true);
RawKeys back = chars({'`'});
back.alt = true;
ev = press(m3, back);
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
TEST_ASSERT_TRUE(ev[0].alt);
}
// Issue #83: Fn+p everywhere.
void test_fn_p_is_a_screenshot_in_both_modes() {
for (bool typing : {true, false}) {
@@ -294,6 +323,7 @@ int main() {
RUN_TEST(test_other_characters_still_type_when_not_typing);
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
RUN_TEST(test_fn_h_is_help_in_both_modes);
RUN_TEST(test_arrows_enter_and_tab_carry_what_was_held);
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
RUN_TEST(test_plain_h_still_types);
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
+291
View File
@@ -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();
}