diff --git a/README.md b/README.md index d01a8f3..cb42945 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,8 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `tls [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip | | `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected | | `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session | +| `scp [-f] [-P port] user@host:path` / `scp [-f] [-P port] user@host:path ` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it | +| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) | | `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug on` / `debug token ` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index a77c619..015e20c 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -360,10 +360,12 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) { } else if (verb == "quit") { quit_ = true; send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest})); + } else if (verb == "uname") { // shown here, not said to anyone + info(b, unameString(), utc); } else if (verb == "raw" || verb == "quote") { if (!rest.empty()) send(rest); } else { - info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc); + info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /uname /quit /raw)", utc); } } diff --git a/lib/term/src/scp_args.cpp b/lib/term/src/scp_args.cpp new file mode 100644 index 0000000..9c17d04 --- /dev/null +++ b/lib/term/src/scp_args.cpp @@ -0,0 +1,81 @@ +#include "scp_args.h" + +#include + +namespace roro::term { + +namespace { +std::vector words(const std::string& text) { + std::vector out; + for (size_t at = 0; at < text.size();) { + size_t end = text.find(' ', at); + if (end == std::string::npos) end = text.size(); + if (end > at) out.push_back(text.substr(at, end - at)); + at = end + 1; + } + return out; +} + +// user@host:path, as opposed to a path on the card. +bool isRemote(const std::string& w) { + if (w.empty() || w[0] == '/') return false; + size_t at = w.find('@'), colon = w.find(':'); + return at != std::string::npos && colon != std::string::npos && at < colon; +} + +std::string baseName(const std::string& path) { + size_t slash = path.rfind('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} +} // namespace + +std::string parseScp(const std::string& args, ScpArgs& out) { + static const char* kUsage = "scp [-f] [-P port] user@host:path | scp [-f] [-P port] user@host:path "; + std::vector w = words(args); + long port = 0; + bool replace = false; + while (!w.empty() && w[0][0] == '-') { + if (w[0] == "-f") { + replace = true; + w.erase(w.begin()); + } else if (w[0] == "-P" && w.size() >= 2) { + port = 0; + for (char c : w[1]) { + if (c < '0' || c > '9' || port > 65535) return "a port from 1 to 65535"; + port = port * 10 + (c - '0'); + } + if (port < 1 || port > 65535) return "a port from 1 to 65535"; + w.erase(w.begin(), w.begin() + 2); + } else { + return kUsage; + } + } + if (w.size() != 2) return kUsage; + + bool firstRemote = isRemote(w[0]), secondRemote = isRemote(w[1]); + if (firstRemote == secondRemote) return firstRemote ? "one side has to be on the card, not both on servers" : kUsage; + + ScpArgs a; + a.upload = secondRemote; + a.replace = replace; + const std::string& remote = a.upload ? w[1] : w[0]; + std::string local = a.upload ? w[0] : w[1]; + size_t colon = remote.find(':'); + std::string why = parseSshTarget(remote.substr(0, colon), a.target); + if (!why.empty()) return why; + if (port) a.target.port = static_cast(port); + a.remote = remote.substr(colon + 1); + if (a.remote.empty()) return "say where on " + a.target.host + ": user@host:path"; + if (local.empty() || local[0] != '/') return "a path on the card starts with a slash"; + if (local.back() == '/') { + if (a.upload) return "that's a folder: scp copies one file"; + std::string name = baseName(a.remote); + if (name.empty()) return "name the file to fetch"; + local += name; + } + a.local = local; + out = a; + return ""; +} + +} // namespace roro::term diff --git a/lib/term/src/scp_args.h b/lib/term/src/scp_args.h new file mode 100644 index 0000000..be7626e --- /dev/null +++ b/lib/term/src/scp_args.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "ssh_hosts.h" + +namespace roro::term { + +// What `scp` was asked: one file to the card from a server, or from the card to a server. +// scp [-f] [-P port] /notes/a.txt user@host:path (upload) +// scp [-f] [-P port] user@host:path /notes/a.txt (download; a destination ending in / gets the remote file's name) +// The card's paths start with a slash; the server's are the server's own (relative ones start at the home folder). +struct ScpArgs { + bool upload = false; + bool replace = false; // -f: a download may replace a file on the card + SshTarget target; + std::string local, remote; +}; + +// "" or what is wrong with it. +std::string parseScp(const std::string& args, ScpArgs& out); + +} // namespace roro::term diff --git a/lib/version/src/version.cpp b/lib/version/src/version.cpp index 81a3daf..39c9a1e 100644 --- a/lib/version/src/version.cpp +++ b/lib/version/src/version.cpp @@ -8,8 +8,26 @@ #define RORO_VERSION "unknown" #endif +#if __has_include("sdkconfig.h") +#include "sdkconfig.h" +#endif + namespace roro { const char* versionString() { return RORO_VERSION; } +const char* architectureString() { +#if defined(CONFIG_IDF_TARGET_ESP32S3) + return "xtensa-lx7 esp32s3"; +#elif defined(CONFIG_IDF_TARGET_ESP32) + return "xtensa-lx6 esp32"; +#elif defined(CONFIG_IDF_TARGET) + return CONFIG_IDF_TARGET; +#else + return "host"; +#endif +} + +std::string unameString() { return std::string(kProductName) + " " + versionString() + " " + architectureString(); } + } // namespace roro diff --git a/lib/version/src/version.h b/lib/version/src/version.h index 2b85f78..3de650b 100644 --- a/lib/version/src/version.h +++ b/lib/version/src/version.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace roro { constexpr const char* kProductName = "roro9stack"; @@ -9,4 +11,10 @@ constexpr const char* kProductName = "roro9stack"; // that one file, and everything else comes from the build cache (issue #74). const char* versionString(); +// The chip this firmware is built for: "xtensa-lx7 esp32s3". "host" in the native tests. +const char* architectureString(); + +// What `uname` and IRC's /uname report: "roro9stack v0.22.0 xtensa-lx7 esp32s3". +std::string unameString(); + } // namespace roro diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index e0a2b94..029c046 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -42,6 +42,8 @@ Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings ping [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time tls [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected +uname the firmware, its version and the chip it is built for +scp [-f] [-P port] user@host:path | scp [-f] [-P port] user@host:path one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back @@ -58,7 +60,7 @@ get | put | screenshot (Debug Console only) bina quit close the Debug Console connection ``` -In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`. +In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `uname`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`. ## What they do @@ -118,6 +120,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `tls [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip | | `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected | | `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session | +| `scp [-f] [-P port] user@host:path` / `scp [-f] [-P port] user@host:path ` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it | +| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) | | `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug on` / `debug token ` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | diff --git a/site/content/faq.md b/site/content/faq.md index d3e9ed4..781d4b1 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -67,7 +67,7 @@ Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf` ## Can it log in to my server? -Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. +Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. `scp` in the [Shell](/guide/shell/) copies a file between the card and a server. ## How do I copy files to and from my phone? diff --git a/site/content/guide/irc.md b/site/content/guide/irc.md index 5a7dd62..bc43d6d 100644 --- a/site/content/guide/irc.md +++ b/site/content/guide/irc.md @@ -31,10 +31,13 @@ You type at the bottom; Enter sends. A line starting with `/` is a co | `/nick newnick` | Changes your nick | | `/topic [text]` | Shows or sets the channel topic | | `/names [#channel]` | Lists who is there | +| `/uname` | Shows the firmware, its version and the chip, in this buffer only: nothing is sent | | `/quit [message]` | Disconnects and **stays disconnected** until you type something again | | `/raw …` (or `/quote`) | Sends a line to the server as it is | | `/settings` | The settings page | +Anyone who asks the device for its CTCP `VERSION` (`/ctcp roro VERSION` in most clients) is told `roro9stack` and the firmware version. + Each server, channel and private chat is a **buffer**. Tab moves to the next one, each shows how many messages are unread, and your own lines are in the accent colour. Scroll back with Alt + ; (older) and Alt + . (newer). The up and down arrows (Fn + ; and .) recall lines you sent. ## Mentions and the unread count diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md index cfa6837..9d06670 100644 --- a/site/content/guide/shell.md +++ b/site/content/guide/shell.md @@ -21,6 +21,7 @@ Type a command and press Enter. `help` lists them all; the [command r | `wifi status` | The network, the address, and where the DNS and time servers came from | | `ls /notes` | A folder of the SD card, with sizes and dates | | `crash` | The last crash, if there was one | +| `uname` | The firmware, its version and the chip it is built for: `roro9stack v0.22.0 xtensa-lx7 esp32s3` | | `update check` | Whether a newer release exists | | `lora status` | What the radio is set to and what it has heard | @@ -45,7 +46,7 @@ The capital is the difference: every command is in small letters, every App star ## The network -For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes; one runs at a time, and `cancel` stops it. +For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes, and so does `scp`; one runs at a time, and `cancel` stops it. | Command | Tells you | |---|---| @@ -56,6 +57,7 @@ For the day the network doesn't do what it should. Each of the first six takes a | `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) | | `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow | | `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session | +| `scp /notes/a.txt user@host:docs/` | One file between the card and a server, over [SSH](/guide/ssh/#copying-a-file-scp): with the device's key, or a password asked in the Shell, masked. `cancel` stops it | | `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers | | `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all | | `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now | diff --git a/site/content/guide/ssh.md b/site/content/guide/ssh.md index fd0606b..ea182ae 100644 --- a/site/content/guide/ssh.md +++ b/site/content/guide/ssh.md @@ -9,7 +9,7 @@ screens = ["ssh.png", "ssh-trust.png", "ssh-terminal.png", "ssh-key.png", "ssh-c The SSH App opens a **terminal on another machine**: a server, a Raspberry Pi, a router. What you type goes there, and what its programs print is drawn here: a shell, `less`, `top`, `nano`, `vim`. -It is a client and nothing more: one session at a time, to a shell. No file transfer, no port forwarding, no jump hosts. +It is a client and nothing more: one session at a time, to a shell. No port forwarding, no jump hosts. Files go one at a time with [`scp` from the Shell](#copying-a-file-scp). ## Connecting @@ -89,6 +89,26 @@ A session takes about **50 KB** of the device's 100 KB while it is open, and giv See [When a connection says "not enough memory"](/howto/not-enough-memory/). +## Copying a file: scp + +From the [Shell](/guide/shell/), `scp` copies **one file** between the SD card and a server, over the same SSH: + +``` +scp /notes/a.txt user@host:docs/a.txt the card to the server +scp user@host:/var/log/syslog /logs/ the server to the card, keeping its name +scp -P 2222 /notes/a.txt user@host: another port (-P first); a path is needed after the colon +scp -f user@host:x.txt /notes/x.txt replace a file that is on the card already +``` + +Paths on the card start with a slash. A path on the server is the server's own, and starts in the user's home folder when it has no slash. Only a file: no folders, no `*`. + +- **The server has to be one you have already trusted** in this App (the fingerprint question above). `scp` can't ask that question, and refuses a server it doesn't know, or one whose key changed. +- **It logs in with the device's key, and asks for the password** if the server doesn't accept the key: the Shell shows the question and what you type is masked (Back cancels). The password is asked only in the Shell, not from a PC on the USB port or the Debug Console. +- **A download arrives under a temporary name** and takes its real one when it is all there, so a cut connection never leaves half a file. A file already there is not replaced without `-f`. +- It prints how long it took. `cancel` stops it. It needs the same 75 KB of free memory as a session, and **not while a session is open**. + +See [Copy a file to or from a server](/howto/scp/). + ## From the Shell ``` @@ -96,6 +116,7 @@ ssh user@host connect, in the SSH App ssh user@host:2222 on another port ssh status the session, and this device's public key ssh stop end the session +scp ... copy one file, see above ``` ## Keys diff --git a/site/content/howto/_index.md b/site/content/howto/_index.md index 0b97698..886c29b 100644 --- a/site/content/howto/_index.md +++ b/site/content/howto/_index.md @@ -1,6 +1,6 @@ +++ title = "How-tos" -description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work." +description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, copy a file to or from a server, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work." template = "guide-index.html" page_template = "guide-page.html" sort_by = "weight" diff --git a/site/content/howto/scp.md b/site/content/howto/scp.md new file mode 100644 index 0000000..6b490f7 --- /dev/null +++ b/site/content/howto/scp.md @@ -0,0 +1,40 @@ ++++ +title = "Copy a file to or from a server" +description = "Send a note from the SD card to a server, or fetch a log from it, with scp in the Shell." +weight = 14 +[extra] +tag = "SSH" ++++ + +One file at a time, between the SD card and a server you can log in to over SSH. + +1. **Connect to the server once in the SSH App** (**SSH → New connection**, `user@host`) and trust its fingerprint. `scp` can't ask that question, so it only goes to servers it has met there. +2. For no password to type, put the device's key on the server: [Log in to a server without a password](/howto/ssh-key/). Without it, `scp` asks for the password. +3. Open the **Shell** and type, for a file going **to** the server: + + ``` + scp /notes/a.txt user@host:docs/a.txt + ``` + + or one coming **from** it, into a folder on the card (it keeps its name): + + ``` + scp user@host:/var/log/syslog /logs/ + ``` + + You should see `scp: ... (1234 bytes) to ...`, then `scp: sent 1234 bytes in 310 ms` (or `received`). +4. If it says `password for host`, type it and press Enter: it shows as stars and is kept nowhere. Back cancels. + +## If it doesn't work + +| It says | Do | +|---|---| +| `is not a server this device trusts yet` | Step 1: connect once in the SSH App | +| `showed a different key than the one remembered` | The server was reinstalled, or something answers in its place. Check in the SSH App, which asks what to do | +| `exists already` | The file is on the card: add `-f` (`scp -f user@host:x /notes/x`) to replace it | +| `An SSH session is open` or `Not enough memory` | End the session (`ssh stop`), or close IRC or a Gemini page: [not enough memory](/howto/not-enough-memory/) | +| `not logged in`, `wrong password` | Three tries, then it stops. Check the user, or the key on the server | +| `a password is only asked in the Shell` | You typed it from a PC on the USB port or the Debug Console: use the Shell, or put the key on the server | +| `that isn't a plain file` | `scp` copies one file, not a folder | + +Another port goes first: `scp -P 2222 /notes/a.txt user@host:a.txt`. `cancel` stops a copy that is running. diff --git a/site/content/howto/ssh-key.md b/site/content/howto/ssh-key.md index 62245be..9d0a0be 100644 --- a/site/content/howto/ssh-key.md +++ b/site/content/howto/ssh-key.md @@ -17,7 +17,7 @@ Typing a password on a small keyboard, every time, gets old. With a key, the ser chmod 600 ~/.ssh/authorized_keys ``` -4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything. +4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything. The same key is what [`scp`](/howto/scp/) uses. ## If it still asks for a password diff --git a/src/apps/shell_app.cpp b/src/apps/shell_app.cpp index 549742b..8b970ea 100644 --- a/src/apps/shell_app.cpp +++ b/src/apps/shell_app.cpp @@ -33,6 +33,9 @@ void ShellApp::onEnter() { // Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207). void ShellApp::onExit() { + if (password_ && abort) abort(); + password_ = false; + input_.setText(""); console.closeShellRing(); console.shellShowsAll(false); open_ = false; @@ -49,6 +52,11 @@ void ShellApp::update(uint32_t) { if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]"); log_.feed(reinterpret_cast(buf), n); } + if (!password_ && asking && asking()) { // after the question has been printed above + password_ = true; + input_.setText(""); + requestRedraw(); + } if (log_.revision() != seenRevision_) { seenRevision_ = log_.revision(); requestRedraw(); @@ -114,6 +122,23 @@ bool ShellApp::onKey(const KeyEvent& e) { log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands."); return true; } + if (password_) { // masked, kept nowhere: not in the history, not in the log + switch (e.key) { + case Key::Char: input_.insert(e.ch); break; + case Key::Delete: input_.backspace(); break; + case Key::Select: { + std::string typed = input_.text(); + input_.setText(""); + password_ = false; + log_.add("> " + std::string(typed.size(), '*')); + if (answer) answer(typed); + typed.assign(typed.size(), '\0'); + break; + } + default: return false; // Back leaves the Shell, which cancels the copy + } + return true; + } std::string recalled; switch (e.key) { case Key::Char: input_.insert(e.ch); break; @@ -197,7 +222,13 @@ void ShellApp::draw(Canvas& c) { } c.setTextDatum(top_left); - widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); + if (password_) { + LineEditor stars(240); + stars.setText(std::string(input_.text().size(), '*')); + widgets::lineEditor(c, stars, {2, area.y + area.h - inputH, area.w - 4, 0}); + } else { + widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); + } if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_); } diff --git a/src/apps/shell_app.h b/src/apps/shell_app.h index 0b17bfd..02166fb 100644 --- a/src/apps/shell_app.h +++ b/src/apps/shell_app.h @@ -36,6 +36,12 @@ class ShellApp : public App { ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps) : run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {} + // `scp` asking for a password: while asking() is true, the next line typed is masked and goes to + // answer() instead of the console; leaving the Shell calls abort(). + std::function asking; + std::function answer; + std::function abort; + void onEnter() override; void onExit() override; bool onKey(const KeyEvent& e) override; @@ -63,6 +69,7 @@ class ShellApp : public App { uint32_t ringPos_ = 0, seenRevision_ = 0; int scroll_ = 0; // wrapped lines scrolled back from the bottom bool open_ = false; + bool password_ = false; // the line being typed is a password }; } // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 12d6029..aa16c12 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include "apps/shell_app.h" #include "apps/ssh_app.h" #include "services/net_tools.h" +#include "services/scp_service.h" #include "services/ssh_service.h" #include "services/vpn_service.h" #include "services/web_share.h" @@ -63,6 +64,7 @@ #include "storage_paths.h" #include "ui/notifier.h" #include "ui/screen.h" +#include "scp_args.h" #include "version.h" using namespace roro; @@ -95,6 +97,8 @@ static VpnService* vpnService; // not in Safe Mode static NetTools netTools; // ping, nslookup and the rest (issue #90) static SshService* sshService; // not in Safe Mode (issue #2) static SshApp* sshApp; +static ShellApp* shellApp; +static ScpService* scpService; // not in Safe Mode static Notifier* notifier; static LauncherApp launcher; static AppManager* apps; @@ -235,8 +239,13 @@ void setup() { apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); sshService = new SshService(settings); sshApp = new SshApp(*sshService, settings, *storageService); + scpService = new ScpService(settings, *storageService, *fileOps); apps->registerApp({"ssh", "SSH", false, sshApp}); - apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)}); + shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps); + apps->registerApp({"shell", "Shell", false, shellApp}); + shellApp->asking = [] { return scpService->asking(); }; + shellApp->answer = [](const std::string& password) { scpService->answer(password); }; + shellApp->abort = [] { scpService->cancel(); }; // Leaving the foreground App makes it save: a note being typed, when the device is powered off. power->beforePowerOff = []() { apps->home(); }; netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); }; @@ -688,6 +697,8 @@ static const char* const kHelp = "ping [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time\n" "tls [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n" "ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n" + "uname the firmware, its version and the chip it is built for\n" + "scp [-f] [-P port] user@host:path | scp [-f] [-P port] user@host:path one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\n" "ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n" "vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n" "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" @@ -771,6 +782,16 @@ static void sshCommand(const String& arg) { } } +// `scp ...`: see ScpService. It needs the key and the trusted server the SSH App sets up. +static void scpCommand(const String& arg) { + if (!scpService) return (void)console.println("scp: not available in Safe Mode"); + term::ScpArgs args; + std::string why = term::parseScp(arg.c_str(), args); + if (!why.empty()) return (void)console.printf("scp: %s\n", why.c_str()); + why = scpService->start(args, sshService && sshService->state() != SshService::State::Idle && sshService->state() != SshService::State::Ended); + if (!why.empty()) console.printf("scp: error %s\n", why.c_str()); +} + // `vpn ...` (issue #8). Nothing here prints a key. static void vpnCommand(const String& arg) { if (!vpnService) return (void)console.println("vpn: not available in Safe Mode"); @@ -840,7 +861,7 @@ static void screenshotStep() { // Commands that only touch what Safe Mode starts. static bool safeModeCommand(const String& line) { - return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || + return line == "help" || line == "uname" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") || line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug "); } @@ -905,6 +926,9 @@ static void runCommand(String line, bool fromSerial = false) { } if (netTools.command(line.c_str())) return; if (line == "cancel") netTools.cancel(); // and a copy or a delete, below + if (line == "uname" || line == "uname -a") return (void)console.println(unameString().c_str()); + if (line.startsWith("scp ")) return scpCommand(line.substring(4)); + if (line == "cancel" && scpService) scpService->cancel(); if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status")); if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status")); if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial); diff --git a/src/platform/libssh_util.cpp b/src/platform/libssh_util.cpp new file mode 100644 index 0000000..b39c809 --- /dev/null +++ b/src/platform/libssh_util.cpp @@ -0,0 +1,29 @@ +#include "platform/libssh_util.h" + +#include + +namespace roro::sshutil { + +std::string fingerprintOf(ssh_session s) { + ssh_key key = nullptr; + unsigned char* hash = nullptr; + size_t len = 0; + std::string out; + if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) { + if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) { + out = text; // "SHA256:..." + ssh_string_free_char(text); + } + ssh_clean_pubkey_hash(&hash); + } + if (key) ssh_key_free(key); + return out; +} + +void startLibrary() { + static bool started = false; + if (!started) libssh_begin(); + started = true; +} + +} // namespace roro::sshutil diff --git a/src/platform/libssh_util.h b/src/platform/libssh_util.h new file mode 100644 index 0000000..5cce891 --- /dev/null +++ b/src/platform/libssh_util.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include + +// What the SSH Service and the Scp Service both do with libssh. +namespace roro::sshutil { + +// libssh_begin(), once. +void startLibrary(); +// "SHA256:..." of the key the server showed, or "". +std::string fingerprintOf(ssh_session s); + +} // namespace roro::sshutil diff --git a/src/services/scp_service.cpp b/src/services/scp_service.cpp new file mode 100644 index 0000000..9891ab5 --- /dev/null +++ b/src/services/scp_service.cpp @@ -0,0 +1,269 @@ +#include "services/scp_service.h" + +#include +#include + +#include + +#include + +#include "platform/libssh_util.h" + +namespace roro { + +namespace { +constexpr uint32_t kStack = 20480; // as the SSH Service's +constexpr size_t kPiece = 4096; + +std::string baseName(const std::string& path) { + size_t slash = path.rfind('/'); + return slash == std::string::npos ? path : path.substr(slash + 1); +} +} // namespace + +struct ScpService::Job { + ScpService* owner; + Console::Origin from; + term::ScpArgs args; + std::string privateKey, known; + + bool stopped() const { return owner->stop_; } + bool onCard(const std::function& work) { return owner->storage_.runAndWait(work); } + void run(); + bool login(ssh_session s); + void upload(ssh_session s); + void download(ssh_session s); +}; + +std::string ScpService::start(const term::ScpArgs& args, bool sshOpen) { + if (busy_) return "A copy is running: `cancel` stops it"; + if (sshOpen) return "An SSH session is open: close it first, there isn't memory for both"; + if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page"; + std::string key = settings_.getString(Setting::SshKey); + if (!args.upload) { + if (std::string why = files_.whyReadOnly(args.local, false); !why.empty()) return why; + bool exists = false; + if (!storage_.runAndWait([&]() { exists = SD.exists(args.local.c_str()); })) return "The card isn't answering"; + if (exists && !args.replace) return args.local + " exists already: scp -f replaces it"; + } + auto* job = new Job{this, console.origin(), args, key, term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(args.target.hostPort())}; + stop_ = asking_ = answered_ = false; + busy_ = true; + if (xTaskCreate(task, "scp", kStack, job, 1, nullptr) != pdPASS) { + busy_ = false; + job->privateKey.assign(job->privateKey.size(), '\0'); + delete job; + return "Not enough memory for it"; + } + return ""; +} + +void ScpService::task(void* arg) { + Job* job = static_cast(arg); + { + Console::As as(job->from); + job->run(); + } + job->privateKey.assign(job->privateKey.size(), '\0'); + ScpService* owner = job->owner; + delete job; + owner->busy_ = false; + vTaskDelete(nullptr); +} + +void ScpService::answer(const std::string& password) { + if (!asking_) return; + password_ = password; + asking_ = false; + answered_ = true; +} + +// This device's key first, then the password, three tries: asked in the Shell that typed the command. +bool ScpService::Job::login(ssh_session s) { + int rc = ssh_userauth_none(s, nullptr); + if (rc == SSH_AUTH_SUCCESS) return true; + int methods = ssh_userauth_list(s, nullptr); + if (!privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) { + ssh_key key = nullptr; + if (ssh_pki_import_privkey_base64(privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) { + rc = ssh_userauth_publickey(s, nullptr, key); + ssh_key_free(key); + } + } + privateKey.assign(privateKey.size(), '\0'); + if (rc == SSH_AUTH_SUCCESS) return true; + if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) { + console.println("scp: error the server takes neither this device's key nor a password"); + return false; + } + if (from != Console::Origin::Shell) { + console.println("scp: error the server doesn't know this device's key, and a password is only asked in the Shell"); + return false; + } + for (int tries = 0; tries < 3 && !stopped(); tries++) { + console.printf("scp: %s's password for %s (Back cancels):\n", args.target.user.c_str(), args.target.host.c_str()); + owner->answered_ = false; + owner->asking_ = true; + while (!owner->answered_ && !stopped()) vTaskDelay(pdMS_TO_TICKS(50)); + owner->asking_ = false; + if (stopped()) break; + std::string password; + password.swap(owner->password_); + if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str()); + else { // asked as questions: every one gets the password + rc = ssh_userauth_kbdint(s, nullptr, nullptr); + for (int round = 0; rc == SSH_AUTH_INFO && round < 4; round++) { + int n = ssh_userauth_kbdint_getnprompts(s); + for (int i = 0; i < n; i++) ssh_userauth_kbdint_setanswer(s, static_cast(i), password.c_str()); + rc = ssh_userauth_kbdint(s, nullptr, nullptr); + } + } + password.assign(password.size(), '\0'); + if (rc == SSH_AUTH_SUCCESS) return true; + if (rc == SSH_AUTH_ERROR) break; + console.println("scp: wrong password"); + } + console.println(stopped() ? "scp: stopped" : "scp: error not logged in"); + return false; +} + +void ScpService::Job::run() { + sshutil::startLibrary(); + ssh_session s = ssh_new(); + if (!s) return (void)console.println("scp: error not enough memory"); + int verbosity = SSH_LOG_NOLOG, port = args.target.port; + long timeout = 10; + ssh_options_set(s, SSH_OPTIONS_HOST, args.target.host.c_str()); + ssh_options_set(s, SSH_OPTIONS_PORT, &port); + ssh_options_set(s, SSH_OPTIONS_USER, args.target.user.c_str()); + ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout); + ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity); + if (ssh_connect(s) != SSH_OK) { + console.printf("scp: error no connection to %s: %s\n", args.target.hostPort().c_str(), ssh_get_error(s)); + } else if (known.empty()) { + console.printf("scp: error %s is not a server this device trusts yet: connect with the SSH App once\n", args.target.hostPort().c_str()); + } else if (sshutil::fingerprintOf(s) != known) { + console.printf("scp: error %s showed a different key than the one remembered: not connected\n", args.target.hostPort().c_str()); + } else if (login(s)) { + if (args.upload) upload(s); + else download(s); + } + ssh_disconnect(s); + ssh_free(s); +} + +void ScpService::Job::upload(ssh_session s) { + // The remote path is where the file goes; the name pushed is only used if that is a folder. + ssh_scp scp = ssh_scp_new(s, SSH_SCP_WRITE, args.remote.c_str()); + if (!scp || ssh_scp_init(scp) != SSH_OK) { + console.printf("scp: error %s\n", ssh_get_error(s)); + if (scp) ssh_scp_free(scp); + return; + } + File f; + size_t size = 0; + bool opened = onCard([&]() { + f = SD.open(args.local.c_str()); + if (f && !f.isDirectory()) size = f.size(); + else if (f) f.close(); + }); + if (!opened || !f) { + console.printf("scp: error cannot read %s\n", args.local.c_str()); + ssh_scp_close(scp); + ssh_scp_free(scp); + return; + } + console.printf("scp: %s (%u bytes) to %s:%s\n", args.local.c_str(), (unsigned)size, args.target.text().c_str(), args.remote.c_str()); + bool ok = ssh_scp_push_file(scp, baseName(args.local).c_str(), size, 0644) == SSH_OK; + uint8_t buf[kPiece]; + uint32_t started = millis(); + size_t sent = 0; + while (ok && sent < size && !stopped()) { + int n = 0; + onCard([&]() { n = f.read(buf, std::min(sizeof buf, size - sent)); }); + if (n <= 0) { + console.printf("scp: error the card stopped reading at %u of %u\n", (unsigned)sent, (unsigned)size); + ok = false; + break; + } + if (ssh_scp_write(scp, buf, static_cast(n)) != SSH_OK) { + console.printf("scp: error %s\n", ssh_get_error(s)); + ok = false; + break; + } + sent += static_cast(n); + } + onCard([&]() { f.close(); }); + if (ok && stopped()) console.printf("scp: stopped at %u of %u bytes\n", (unsigned)sent, (unsigned)size); + else if (ok) console.printf("scp: sent %u bytes in %lu ms\n", (unsigned)sent, (unsigned long)(millis() - started)); + else if (!ok && sent == 0) console.printf("scp: error the server refused it: %s\n", ssh_get_error(s)); + ssh_scp_close(scp); + ssh_scp_free(scp); +} + +void ScpService::Job::download(ssh_session s) { + ssh_scp scp = ssh_scp_new(s, SSH_SCP_READ, args.remote.c_str()); + if (!scp || ssh_scp_init(scp) != SSH_OK) { + console.printf("scp: error %s\n", ssh_get_error(s)); + if (scp) ssh_scp_free(scp); + return; + } + // Into a part file, renamed once it is all there: a failure never leaves half a file under the real name. + std::string part = args.local + ".part"; + File f; + bool ok = false; + size_t got = 0, size = 0; + uint32_t started = millis(); + if (ssh_scp_pull_request(scp) != SSH_SCP_REQUEST_NEWFILE) { + const char* why = ssh_get_error(s); + console.printf("scp: error %s\n", why && *why ? why : "that isn't a plain file: scp copies one file"); + } else { + size = static_cast(ssh_scp_request_get_size64(scp)); + ssh_scp_accept_request(scp); + console.printf("scp: %s:%s (%u bytes) to %s\n", args.target.text().c_str(), args.remote.c_str(), (unsigned)size, args.local.c_str()); + bool opened = false; + onCard([&]() { + size_t slash = args.local.rfind('/'); + if (slash > 0 && !SD.exists(args.local.substr(0, slash).c_str())) SD.mkdir(args.local.substr(0, slash).c_str()); + if (SD.exists(part.c_str())) SD.remove(part.c_str()); + f = SD.open(part.c_str(), FILE_WRITE); + opened = static_cast(f); + }); + if (!opened) console.printf("scp: error cannot write %s\n", args.local.c_str()); + ok = opened; + uint8_t buf[kPiece]; + while (ok && got < size && !stopped()) { + int n = ssh_scp_read(scp, buf, std::min(sizeof buf, size - got)); + if (n <= 0) { + console.printf("scp: error the connection ended at %u of %u bytes\n", (unsigned)got, (unsigned)size); + ok = false; + break; + } + size_t written = 0; + onCard([&]() { written = f.write(buf, static_cast(n)); }); + if (written != static_cast(n)) { + console.println("scp: error the card refused the write: is it full?"); + ok = false; + break; + } + got += static_cast(n); + } + if (ok && got < size) ok = false, console.printf("scp: stopped at %u of %u bytes\n", (unsigned)got, (unsigned)size); + } + bool renamed = false; + onCard([&]() { + if (f) f.close(); + if (ok) { + if (SD.exists(args.local.c_str())) SD.remove(args.local.c_str()); + renamed = SD.rename(part.c_str(), args.local.c_str()); + } else if (SD.exists(part.c_str())) { + SD.remove(part.c_str()); + } + }); + if (ok && !renamed) console.println("scp: error the card refused the new name"); + else if (ok) console.printf("scp: received %u bytes in %lu ms\n", (unsigned)got, (unsigned long)(millis() - started)); + ssh_scp_close(scp); + ssh_scp_free(scp); +} + +} // namespace roro diff --git a/src/services/scp_service.h b/src/services/scp_service.h new file mode 100644 index 0000000..93f04d5 --- /dev/null +++ b/src/services/scp_service.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "platform/console.h" +#include "scp_args.h" +#include "services/file_ops.h" +#include "services/storage_service.h" +#include "settings.h" + +namespace roro { + +// `scp`: one file between the card and a server, over SSH (libssh's SCP), on a task of its own that +// prints to the console that asked. It logs in with this device's key and, if the server doesn't +// know it, asks for the password: but only when the command was typed in the Shell, which draws +// the question masked (asking(), answer()). It goes only to servers the SSH App has already been +// told to trust. One at a time, and not while an SSH session is open (they don't fit in memory +// together). +class ScpService { + public: + static constexpr size_t kNeedFree = 75 * 1024; + + ScpService(Settings& settings, StorageService& storage, FileOps& files) : settings_(settings), storage_(storage), files_(files) {} + + // "" when it started, or why not. + std::string start(const term::ScpArgs& args, bool sshOpen); + void cancel() { + stop_ = true; + asking_ = false; + answered_ = true; // whatever it was waiting for + } + bool busy() const { return busy_; } + // The password is wanted (for user@host, in `who`); the Shell calls answer() with what was typed. + bool asking() const { return asking_; } + void answer(const std::string& password); + + private: + struct Job; + static void task(void* arg); + + Settings& settings_; + StorageService& storage_; + FileOps& files_; + std::atomic busy_{false}, stop_{false}, asking_{false}, answered_{false}; + std::string password_; // written by answer() before answered_ is set, read by the task after +}; + +} // namespace roro diff --git a/src/services/ssh_service.cpp b/src/services/ssh_service.cpp index 82f70b3..6b039e6 100644 --- a/src/services/ssh_service.cpp +++ b/src/services/ssh_service.cpp @@ -5,6 +5,8 @@ #include #include +#include "platform/libssh_util.h" + namespace roro { namespace { @@ -17,27 +19,6 @@ struct Locked { SemaphoreHandle_t lock_; }; -std::string fingerprintOf(ssh_session s) { - ssh_key key = nullptr; - unsigned char* hash = nullptr; - size_t len = 0; - std::string out; - if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) { - if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) { - out = text; // "SHA256:..." - ssh_string_free_char(text); - } - ssh_clean_pubkey_hash(&hash); - } - if (key) ssh_key_free(key); - return out; -} - -void startLibrary() { - static bool started = false; - if (!started) libssh_begin(); - started = true; -} } // namespace struct SshService::Run { @@ -170,7 +151,7 @@ void SshService::task(void* arg) { } void SshService::session(Run& run) { - startLibrary(); + sshutil::startLibrary(); ssh_session s = ssh_new(); if (!s) return end("Not enough memory for the session"); int verbosity = SSH_LOG_NOLOG; @@ -204,7 +185,7 @@ void SshService::session(Run& run) { if (stop_) return fail("Stopped"); // Is it the server it was last time? The first time, and when it has changed, the user decides. - std::string seen = fingerprintOf(s); + std::string seen = sshutil::fingerprintOf(s); if (seen.empty()) return fail("The server showed no key"); if (seen != run.known) { { @@ -333,7 +314,7 @@ std::string SshService::makeKey() { auto work = [](void* p) { std::shared_ptr m = *static_cast*>(p); delete static_cast*>(p); - startLibrary(); + sshutil::startLibrary(); ssh_key key = nullptr, pub = nullptr; char *b64 = nullptr, *pub64 = nullptr; if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made"; diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index aeed853..9b5cc77 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -330,6 +330,18 @@ void test_action_and_ctcp_version() { TEST_ASSERT_TRUE(f.sent(std::string("NOTICE alice :\x01VERSION roro9stack ") + versionString() + "\x01")); } +void test_uname_shows_the_firmware_here_and_sends_nothing() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.take(); + f.session->input(f.buffer("#roro"), "/uname", 0); + const auto& line = f.session->buffer(f.buffer("#roro")).lines.back(); + TEST_ASSERT_EQUAL(static_cast(IrcLine::Kind::Info), static_cast(line.kind)); + TEST_ASSERT_EQUAL_STRING(unameString().c_str(), line.text.c_str()); + TEST_ASSERT_EQUAL(0, f.take().send.size()); +} + void test_topic_is_kept() { Fixture f; f.registerNow(); @@ -492,6 +504,7 @@ int main() { RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies); RUN_TEST(test_no_notification_while_viewing_that_buffer); RUN_TEST(test_action_and_ctcp_version); + RUN_TEST(test_uname_shows_the_firmware_here_and_sends_nothing); RUN_TEST(test_topic_is_kept); RUN_TEST(test_input_message_and_commands); RUN_TEST(test_j_is_short_for_join); diff --git a/test/test_scp_args/test_scp_args.cpp b/test/test_scp_args/test_scp_args.cpp new file mode 100644 index 0000000..91a1d97 --- /dev/null +++ b/test/test_scp_args/test_scp_args.cpp @@ -0,0 +1,66 @@ +#include + +#include "scp_args.h" + +using roro::term::ScpArgs; +using roro::term::parseScp; + +void setUp() {} +void tearDown() {} + +void test_upload() { + ScpArgs a; + TEST_ASSERT_EQUAL_STRING("", parseScp("/notes/a.txt bob@nas.lan:docs/a.txt", a).c_str()); + TEST_ASSERT_TRUE(a.upload); + TEST_ASSERT_EQUAL_STRING("bob", a.target.user.c_str()); + TEST_ASSERT_EQUAL_STRING("nas.lan", a.target.host.c_str()); + TEST_ASSERT_EQUAL(22, a.target.port); + TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.local.c_str()); + TEST_ASSERT_EQUAL_STRING("docs/a.txt", a.remote.c_str()); +} + +void test_download_and_port() { + ScpArgs a; + TEST_ASSERT_EQUAL_STRING("", parseScp("-P 2222 bob@10.9.0.1:/var/log/syslog /logs/syslog", a).c_str()); + TEST_ASSERT_FALSE(a.upload); + TEST_ASSERT_EQUAL(2222, a.target.port); + TEST_ASSERT_EQUAL_STRING("/var/log/syslog", a.remote.c_str()); + TEST_ASSERT_EQUAL_STRING("/logs/syslog", a.local.c_str()); +} + +void test_download_into_a_folder_takes_the_remote_name() { + ScpArgs a; + TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:/etc/motd /notes/", a).c_str()); + TEST_ASSERT_EQUAL_STRING("/notes/motd", a.local.c_str()); +} + +void test_replace_flag() { + ScpArgs a; + TEST_ASSERT_EQUAL_STRING("", parseScp("-f -P 22 bob@h:x /a", a).c_str()); + TEST_ASSERT_TRUE(a.replace); + TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:x /a", a).c_str()); + TEST_ASSERT_FALSE(a.replace); + TEST_ASSERT_TRUE(parseScp("-r bob@h:x /a", a).size() > 0); +} + +void test_refusals() { + ScpArgs a; + TEST_ASSERT_TRUE(parseScp("", a).size() > 0); + TEST_ASSERT_TRUE(parseScp("/a.txt /b.txt", a).size() > 0); // neither is a server + TEST_ASSERT_TRUE(parseScp("a@h:x b@h:y", a).size() > 0); // both are + TEST_ASSERT_TRUE(parseScp("a.txt bob@h:x", a).size() > 0); // not a card path + TEST_ASSERT_TRUE(parseScp("/a.txt bob@h:", a).size() > 0); // no remote path + TEST_ASSERT_TRUE(parseScp("/notes/ bob@h:x", a).size() > 0); // a folder + TEST_ASSERT_TRUE(parseScp("-P 0 /a bob@h:x", a).size() > 0); + TEST_ASSERT_TRUE(parseScp("/a bob@bad_host:x", a).size() > 0); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_upload); + RUN_TEST(test_download_and_port); + RUN_TEST(test_download_into_a_folder_takes_the_remote_name); + RUN_TEST(test_replace_flag); + RUN_TEST(test_refusals); + return UNITY_END(); +} diff --git a/test/test_version/test_version.cpp b/test/test_version/test_version.cpp index 2dab2a3..c0e7d0b 100644 --- a/test/test_version/test_version.cpp +++ b/test/test_version/test_version.cpp @@ -14,9 +14,16 @@ void test_product_name() { TEST_ASSERT_EQUAL_STRING("roro9stack", roro::kProductName); } +void test_uname_has_name_version_and_architecture() { + std::string expected = std::string("roro9stack ") + roro::versionString() + " " + roro::architectureString(); + TEST_ASSERT_EQUAL_STRING(expected.c_str(), roro::unameString().c_str()); + TEST_ASSERT_TRUE(std::strlen(roro::architectureString()) > 0); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_version_is_never_empty); RUN_TEST(test_product_name); + RUN_TEST(test_uname_has_name_version_and_architecture); return UNITY_END(); }