1003 lines
35 KiB
C++

#include "doctest.h"
#include <development/credentials.h>
#include <development/protocol.h>
#include <development/server.h>
#include <development/session.h>
#include <app/event.h>
#include <app/io.h>
#include <app/loader.h>
#include <app/manager.h>
#include <app/package_manifest.h>
#include <service/manager.h>
#include <service/paths.h>
#include <tactility/delay.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/freertos/task.h>
#include <tactility/paths.h>
#include <mbedtls/net_sockets.h>
#include <mbedtls/ssl.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <memory>
#include <string>
extern ServiceManifest app_internal_loader_service_manifest;
namespace {
const char* TEST_ROOT = "/tmp/tactility_development_test";
constexpr uint32_t TIMEOUT_MS = 10000;
// region Fixture
error_t fakeMount(void*) { return ERROR_NONE; }
error_t fakeUnmount(void*) { return ERROR_NONE; }
bool fakeIsMounted(void*) { return false; }
error_t fakeGetPath(void* data, char* out_path, size_t out_path_size) {
const char* path = static_cast<const char*>(data);
if (strlen(path) + 1 > out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
strcpy(out_path, path);
return ERROR_NONE;
}
const FileSystemApi fakeDataApi = {
.mount = fakeMount,
.unmount = fakeUnmount,
.is_mounted = fakeIsMounted,
.get_path = fakeGetPath,
};
std::atomic<int> promptResult { DEVELOPMENT_PROMPT_REJECT };
std::atomic<int> promptCount { 0 };
char promptMode[16] = {};
char promptFingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1] = {};
int32_t promptMain(int argc, char* argv[]) {
promptCount++;
if (argc == 4) {
strncpy(promptMode, argv[3], sizeof(promptMode) - 1);
strncpy(promptFingerprint, argv[2], sizeof(promptFingerprint) - 1);
}
return promptResult;
}
const AppManifest promptManifest = {
.id = DEVELOPMENT_SERVER_PROMPT_APP_ID,
.name = "Prompt",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(promptMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};
void writeOut(const std::string& text) {
app_io_write(STDOUT_FILENO, text.data(), text.size());
}
// Echoes lines. "exit N" exits with code N and "size" prints the window size. "-c <command>" prints the command and exits with 7.
int32_t shellMain(int argc, char* argv[]) {
if (argc == 3 && std::string(argv[1]) == "-c") {
writeOut("command:" + std::string(argv[2]) + "\n");
return 7;
}
std::string line;
char character;
while (app_io_read(STDIN_FILENO, &character, 1) == 1) {
if (character != '\n') {
line += character;
continue;
}
if (line.starts_with("exit ")) {
return atoi(line.c_str() + 5);
}
if (line == "size") {
AppWindowSize size {};
app_io_ioctl(STDOUT_FILENO, APP_IOCTL_GET_WINDOW_SIZE, &size);
writeOut("size " + std::to_string(size.columns) + "x" + std::to_string(size.rows) + "\n");
} else {
writeOut("echo:" + line + "\n");
}
line.clear();
}
return 0;
}
const AppManifest shellManifest = {
.id = "shell",
.name = "Shell",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(shellMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};
void ensureFixture() {
static bool created = false;
if (created) {
return;
}
created = true;
std::filesystem::remove_all(TEST_ROOT);
std::filesystem::create_directories(TEST_ROOT);
REQUIRE_NE(file_system_add("data", &fakeDataApi, const_cast<char*>(TEST_ROOT)), nullptr);
if (service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID) == nullptr) {
REQUIRE_EQ(service_manager_add(&app_internal_loader_service_manifest, true), ERROR_NONE);
}
REQUIRE_EQ(app_manager_add(&promptManifest), ERROR_NONE);
REQUIRE_EQ(app_manager_add(&shellManifest), ERROR_NONE);
REQUIRE_EQ(app_manager_add(&development_session_app_manifest), ERROR_NONE);
development_server_set_port(0);
REQUIRE_EQ(service_manager_add(&development_server_service_manifest, true), ERROR_NONE);
REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE);
}
/** A client certificate, stored in its own directory like the device certificate */
struct Identity {
DevelopmentCredentials credentials {};
explicit Identity(const char* name) {
const std::string directory = std::string(TEST_ROOT) + "/clients/" + name;
REQUIRE_EQ(development_credentials_load(directory.c_str(), &credentials), ERROR_NONE);
}
~Identity() { development_credentials_free(&credentials); }
};
Identity& identity(int index) {
static Identity first("first");
static Identity second("second");
static Identity third("third");
static Identity fourth("fourth");
static Identity fifth("fifth");
switch (index) {
case 0: return first;
case 1: return second;
case 2: return third;
case 3: return fourth;
default: return fifth;
}
}
bool isExpired(TickType_t start, uint32_t timeoutMs) {
return xTaskGetTickCount() - start > pdMS_TO_TICKS(timeoutMs);
}
// endregion
// region Client
int bioSend(void* context, const unsigned char* data, size_t length) {
const ssize_t sent = send(*static_cast<int*>(context), data, length, MSG_NOSIGNAL);
if (sent >= 0) {
return static_cast<int>(sent);
}
return (errno == EAGAIN || errno == EINTR) ? MBEDTLS_ERR_SSL_WANT_WRITE : MBEDTLS_ERR_NET_SEND_FAILED;
}
int bioReceive(void* context, unsigned char* data, size_t length) {
const ssize_t received = recv(*static_cast<int*>(context), data, length, 0);
if (received > 0) {
return static_cast<int>(received);
}
if (received == 0) {
return MBEDTLS_ERR_NET_CONN_RESET;
}
return (errno == EAGAIN || errno == EINTR) ? MBEDTLS_ERR_SSL_WANT_READ : MBEDTLS_ERR_NET_RECV_FAILED;
}
class TestClient {
int socketFd = -1;
mbedtls_ssl_config config {};
mbedtls_ssl_context ssl {};
std::string received;
bool closed = false;
// @return false on timeout or when the connection closed
bool receiveMore(TickType_t start, uint32_t timeoutMs) {
unsigned char buffer[512];
while (!isExpired(start, timeoutMs)) {
const int result = mbedtls_ssl_read(&ssl, buffer, sizeof(buffer));
if (result > 0) {
received.append(reinterpret_cast<char*>(buffer), result);
return true;
}
if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) {
closed = true;
return false;
}
}
return false;
}
public:
explicit TestClient(Identity* identity) {
mbedtls_ssl_config_init(&config);
mbedtls_ssl_init(&ssl);
REQUIRE_EQ(mbedtls_ssl_config_defaults(&config, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT), 0);
mbedtls_ssl_conf_authmode(&config, MBEDTLS_SSL_VERIFY_NONE);
mbedtls_ssl_conf_max_tls_version(&config, MBEDTLS_SSL_VERSION_TLS1_2);
if (identity != nullptr) {
REQUIRE_EQ(mbedtls_ssl_conf_own_cert(&config, &identity->credentials.certificate, &identity->credentials.key), 0);
}
REQUIRE_EQ(mbedtls_ssl_setup(&ssl, &config), 0);
}
~TestClient() {
if (socketFd >= 0) {
mbedtls_ssl_close_notify(&ssl);
close(socketFd);
}
mbedtls_ssl_free(&ssl);
mbedtls_ssl_config_free(&config);
}
bool connect() {
socketFd = socket(AF_INET, SOCK_STREAM, 0);
REQUIRE(socketFd >= 0);
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_port = htons(development_server_get_port());
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr);
int result;
do {
result = ::connect(socketFd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
} while (result != 0 && errno == EINTR);
REQUIRE_EQ(result, 0);
timeval timeout { .tv_sec = 0, .tv_usec = 100000 };
setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
mbedtls_ssl_set_bio(&ssl, &socketFd, bioSend, bioReceive, nullptr);
const TickType_t start = xTaskGetTickCount();
while ((result = mbedtls_ssl_handshake(&ssl)) != 0) {
if ((result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) || isExpired(start, TIMEOUT_MS)) {
return false;
}
}
return true;
}
std::string serverFingerprint() {
const mbedtls_x509_crt* certificate = mbedtls_ssl_get_peer_cert(&ssl);
REQUIRE_NE(certificate, nullptr);
char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1];
REQUIRE_EQ(development_fingerprint(certificate->raw.p, certificate->raw.len, fingerprint), ERROR_NONE);
return fingerprint;
}
void write(const std::string& data) {
size_t offset = 0;
const TickType_t start = xTaskGetTickCount();
while (offset < data.size()) {
const int result = mbedtls_ssl_write(&ssl, reinterpret_cast<const unsigned char*>(data.data()) + offset, data.size() - offset);
if (result > 0) {
offset += static_cast<size_t>(result);
} else {
REQUIRE((result == MBEDTLS_ERR_SSL_WANT_WRITE || result == MBEDTLS_ERR_SSL_WANT_READ));
REQUIRE_FALSE(isExpired(start, TIMEOUT_MS));
}
}
}
/** @param[in] extraFields JSON fields to add, starting with a comma */
void sendHello(const std::string& mode = "shell", int protocol = DEVELOPMENT_PROTOCOL_VERSION, const std::string& extraFields = "") {
write(R"({"protocol":)" + std::to_string(protocol) + R"(,"mode":")" + mode + R"(","client":"test","columns":100,"rows":30)" + extraFields + "}\n");
}
std::string readLine() {
const TickType_t start = xTaskGetTickCount();
size_t newline;
while ((newline = received.find('\n')) == std::string::npos) {
if (!receiveMore(start, TIMEOUT_MS)) {
return {};
}
}
std::string line = received.substr(0, newline);
received.erase(0, newline + 1);
return line;
}
void sendFrame(DevelopmentFrameType type, const std::string& payload) {
uint8_t header[DEVELOPMENT_FRAME_HEADER_SIZE];
development_frame_write_header(type, static_cast<uint16_t>(payload.size()), header);
write(std::string(reinterpret_cast<char*>(header), sizeof(header)) + payload);
}
void sendResize(uint16_t columns, uint16_t rows) {
const char payload[4] = {
static_cast<char>(columns >> 8), static_cast<char>(columns & 0xFF),
static_cast<char>(rows >> 8), static_cast<char>(rows & 0xFF)
};
sendFrame(DEVELOPMENT_FRAME_RESIZE, std::string(payload, sizeof(payload)));
}
/** @return false on timeout or when the connection closed */
bool readFrame(DevelopmentFrameType& outType, std::string& outPayload) {
const TickType_t start = xTaskGetTickCount();
while (true) {
DevelopmentFrame frame {};
size_t consumed = 0;
const auto result = development_frame_parse(reinterpret_cast<const uint8_t*>(received.data()), received.size(), &frame, &consumed);
REQUIRE_NE(result, DevelopmentFrameParseResult::Invalid);
if (result == DevelopmentFrameParseResult::Ok) {
outType = frame.type;
outPayload.assign(reinterpret_cast<const char*>(frame.payload), frame.length);
received.erase(0, consumed);
return true;
}
if (!receiveMore(start, TIMEOUT_MS)) {
return false;
}
}
}
/** Reads DATA frames until the output contains @a text */
bool readOutputUntil(const std::string& text) {
std::string output;
DevelopmentFrameType type;
std::string payload;
while (output.find(text) == std::string::npos) {
if (!readFrame(type, payload) || type != DEVELOPMENT_FRAME_DATA) {
return false;
}
output += payload;
}
return true;
}
/** @return true when the server closes the connection within the timeout */
bool waitForClose() {
const TickType_t start = xTaskGetTickCount();
while (!closed && !isExpired(start, TIMEOUT_MS)) {
receiveMore(start, TIMEOUT_MS);
}
return closed;
}
/** Closes the connection without a TLS close_notify */
void drop() {
close(socketFd);
socketFd = -1;
}
};
std::string readHostFile(const std::string& path);
/** Connects and sends a hello. @return the status line */
std::string connectAndGreet(TestClient& client, const std::string& mode = "shell", int protocol = DEVELOPMENT_PROTOCOL_VERSION, const std::string& extraFields = "") {
REQUIRE(client.connect());
client.sendHello(mode, protocol, extraFields);
return client.readLine();
}
/** Connects and sends a hello, retrying while a previous session still ends, as that happens asynchronously */
std::unique_ptr<TestClient> greetWhenIdle(Identity* identity, std::string& outStatus, const std::string& mode = "shell", const std::string& extraFields = "") {
const TickType_t start = xTaskGetTickCount();
while (true) {
auto client = std::make_unique<TestClient>(identity);
outStatus = connectAndGreet(*client, mode, DEVELOPMENT_PROTOCOL_VERSION, extraFields);
if (outStatus.find("busy") == std::string::npos) {
return client;
}
REQUIRE_FALSE(isExpired(start, TIMEOUT_MS));
delay_millis(50);
}
}
constexpr auto* ACCEPTED = R"({"status":"accepted"})";
constexpr auto* REJECTED = R"({"status":"rejected"})";
// endregion
} // namespace
TEST_CASE("a client that is accepted once gets a shell") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
promptCount = 0;
std::string status;
auto client = greetWhenIdle(&identity(0), status);
REQUIRE_EQ(status, ACCEPTED);
char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1];
REQUIRE_EQ(development_server_get_fingerprint(fingerprint, sizeof(fingerprint)), ERROR_NONE);
CHECK_EQ(client->serverFingerprint(), std::string(fingerprint));
CHECK_EQ(promptCount, 1);
CHECK_EQ(std::string(promptMode), "shell");
CHECK_EQ(std::string(promptFingerprint), std::string(identity(0).credentials.fingerprint));
client->sendFrame(DEVELOPMENT_FRAME_DATA, "hello\n");
CHECK(client->readOutputUntil("echo:hello\n"));
client->sendFrame(DEVELOPMENT_FRAME_DATA, "size\n");
CHECK(client->readOutputUntil("size 100x30\n"));
client->sendResize(132, 43);
client->sendFrame(DEVELOPMENT_FRAME_DATA, "size\n");
CHECK(client->readOutputUntil("size 132x43\n"));
client->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 3\n");
DevelopmentFrameType type;
std::string payload;
REQUIRE(client->readFrame(type, payload));
CHECK_EQ(type, DEVELOPMENT_FRAME_EXIT);
CHECK_EQ(payload, std::string("\0\0\0\3", 4));
CHECK(client->waitForClose());
}
TEST_CASE("a client that is accepted once is asked for again") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
promptCount = 0;
std::string firstStatus;
auto first = greetWhenIdle(&identity(0), firstStatus);
REQUIRE_EQ(firstStatus, ACCEPTED);
first->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 0\n");
CHECK(first->waitForClose());
std::string secondStatus;
auto second = greetWhenIdle(&identity(0), secondStatus);
REQUIRE_EQ(secondStatus, ACCEPTED);
CHECK_EQ(promptCount, 2);
}
TEST_CASE("a rejected client is disconnected") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_REJECT;
std::string clientStatus;
auto client = greetWhenIdle(&identity(0), clientStatus);
CHECK_EQ(clientStatus, REJECTED);
CHECK(client->waitForClose());
}
TEST_CASE("a prompt result that is not a known answer rejects the client") {
ensureFixture();
promptResult = 7;
std::string clientStatus;
auto client = greetWhenIdle(&identity(0), clientStatus);
CHECK_EQ(clientStatus, REJECTED);
}
TEST_CASE("a client that is always accepted is not asked for again, also after a restart") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS;
promptCount = 0;
std::string firstStatus;
auto first = greetWhenIdle(&identity(1), firstStatus);
REQUIRE_EQ(firstStatus, ACCEPTED);
CHECK_EQ(promptCount, 1);
first->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 0\n");
CHECK(first->waitForClose());
char fingerprintBefore[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1];
REQUIRE_EQ(development_server_get_fingerprint(fingerprintBefore, sizeof(fingerprintBefore)), ERROR_NONE);
REQUIRE_EQ(service_manager_stop(DEVELOPMENT_SERVER_SERVICE_ID), ERROR_NONE);
REQUIRE_EQ(service_manager_start(DEVELOPMENT_SERVER_SERVICE_ID), ERROR_NONE);
// The enabled state was persisted
REQUIRE(development_server_is_enabled());
char fingerprintAfter[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1];
REQUIRE_EQ(development_server_get_fingerprint(fingerprintAfter, sizeof(fingerprintAfter)), ERROR_NONE);
CHECK_EQ(std::string(fingerprintBefore), std::string(fingerprintAfter));
promptResult = DEVELOPMENT_PROMPT_REJECT;
std::string secondStatus;
auto second = greetWhenIdle(&identity(1), secondStatus);
REQUIRE_EQ(secondStatus, ACCEPTED);
CHECK_EQ(promptCount, 1);
second->sendFrame(DEVELOPMENT_FRAME_DATA, "hi\n");
CHECK(second->readOutputUntil("echo:hi\n"));
}
TEST_CASE("a client without certificate is refused") {
ensureFixture();
TestClient anonymous(nullptr);
const std::string status = connectAndGreet(anonymous);
CHECK_NE(status.find("client certificate required"), std::string::npos);
CHECK(anonymous.waitForClose());
}
TEST_CASE("an unsupported mode or protocol version is refused") {
ensureFixture();
promptCount = 0;
TestClient ftp(&identity(0));
CHECK_EQ(connectAndGreet(ftp, "ftp"), R"({"status":"error","message":"unsupported mode"})");
TestClient future(&identity(0));
CHECK_EQ(connectAndGreet(future, "shell", 2), R"({"status":"error","message":"unsupported protocol version"})");
CHECK_EQ(promptCount, 0);
}
TEST_CASE("a shell hello with a command runs it with -c and reports its exit code") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string status;
auto client = greetWhenIdle(&identity(0), status, "shell", R"(,"command":"ls -la /data")");
REQUIRE_EQ(status, ACCEPTED);
CHECK(client->readOutputUntil("command:ls -la /data\n"));
DevelopmentFrameType type;
std::string payload;
REQUIRE(client->readFrame(type, payload));
CHECK_EQ(type, DEVELOPMENT_FRAME_EXIT);
CHECK_EQ(payload, std::string("\0\0\0\7", 4));
CHECK(client->waitForClose());
}
TEST_CASE("a second shell is refused while a shell is active, other modes are not") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string firstStatus;
auto first = greetWhenIdle(&identity(0), firstStatus);
REQUIRE_EQ(firstStatus, ACCEPTED);
TestClient other(&identity(0));
CHECK_EQ(connectAndGreet(other), R"({"status":"error","message":"shell busy"})");
TestClient sync(&identity(0));
CHECK_EQ(connectAndGreet(sync, "sync"), ACCEPTED);
first->sendFrame(DEVELOPMENT_FRAME_DATA, "still here\n");
CHECK(first->readOutputUntil("echo:still here\n"));
}
TEST_CASE("at most 3 sessions run at once") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string status;
auto shell = greetWhenIdle(&identity(0), status);
REQUIRE_EQ(status, ACCEPTED);
auto sync = greetWhenIdle(&identity(0), status, "sync");
REQUIRE_EQ(status, ACCEPTED);
auto app = greetWhenIdle(&identity(0), status, "app");
REQUIRE_EQ(status, ACCEPTED);
TestClient fourth(&identity(0));
CHECK_EQ(connectAndGreet(fourth, "sync"), R"({"status":"error","message":"busy"})");
// All sessions keep working
shell->sendFrame(DEVELOPMENT_FRAME_DATA, "one\n");
CHECK(shell->readOutputUntil("echo:one\n"));
std::string message;
sync->sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, "/");
DevelopmentFrameType type;
REQUIRE(sync->readFrame(type, message));
CHECK_EQ(type, DEVELOPMENT_FRAME_SYNC_STAT_RESULT);
app->sendFrame(DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.not.installed");
REQUIRE(app->readFrame(type, message));
CHECK_EQ(type, DEVELOPMENT_FRAME_OK);
REQUIRE_EQ(development_server_set_enabled(false), ERROR_NONE);
CHECK(shell->waitForClose());
CHECK(sync->waitForClose());
CHECK(app->waitForClose());
REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE);
}
TEST_CASE("clients that are always accepted at the same time are all stored") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS;
// Both hellos are sent before either answer is read, so both sessions prompt and store concurrently.
// The listener handles one handshake and hello at a time.
TestClient first(&identity(3));
TestClient second(&identity(4));
REQUIRE(first.connect());
first.sendHello("sync");
REQUIRE(second.connect());
second.sendHello("sync");
REQUIRE_EQ(first.readLine(), ACCEPTED);
REQUIRE_EQ(second.readLine(), ACCEPTED);
char directory[FILE_MAX_PATH_STRING_LENGTH];
REQUIRE_EQ(service_paths_get_user_data_directory(DEVELOPMENT_SERVER_SERVICE_ID, directory, sizeof(directory)), ERROR_NONE);
const std::string trusted = readHostFile(std::string(directory) + "/trusted_clients.txt");
CHECK_NE(trusted.find(identity(3).credentials.fingerprint), std::string::npos);
CHECK_NE(trusted.find(identity(4).credentials.fingerprint), std::string::npos);
}
TEST_CASE("a session ends when its client disconnects") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
{
std::string firstStatus;
auto first = greetWhenIdle(&identity(0), firstStatus);
REQUIRE_EQ(firstStatus, ACCEPTED);
first->sendFrame(DEVELOPMENT_FRAME_DATA, "partial line without newline");
first->drop();
}
std::string secondStatus;
auto second = greetWhenIdle(&identity(0), secondStatus);
REQUIRE_EQ(secondStatus, ACCEPTED);
second->sendFrame(DEVELOPMENT_FRAME_DATA, "again\n");
CHECK(second->readOutputUntil("echo:again\n"));
}
TEST_CASE("disabling the server ends the active session") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string clientStatus;
auto client = greetWhenIdle(&identity(0), clientStatus);
REQUIRE_EQ(clientStatus, ACCEPTED);
REQUIRE_EQ(development_server_set_enabled(false), ERROR_NONE);
CHECK_FALSE(development_server_is_enabled());
CHECK(client->waitForClose());
REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE);
CHECK(development_server_is_enabled());
}
// region Sync
namespace {
std::string syncPath(const std::string& child) {
return std::string(TEST_ROOT) + "/sync/" + child;
}
std::string readHostFile(const std::string& path) {
FILE* file = fopen(path.c_str(), "rb");
REQUIRE_NE(file, nullptr);
std::string content;
char buffer[4096];
size_t length;
while ((length = fread(buffer, 1, sizeof(buffer), file)) > 0) {
content.append(buffer, length);
}
fclose(file);
return content;
}
std::unique_ptr<TestClient> openSync() {
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string status;
auto client = greetWhenIdle(&identity(0), status, "sync");
REQUIRE_EQ(status, ACCEPTED);
return client;
}
/** @return the reply frame type, with its payload in @a outPayload */
DevelopmentFrameType push(TestClient& client, const std::string& path, const std::string& content, std::string& outPayload, DevelopmentFrameType request = DEVELOPMENT_FRAME_SYNC_PUSH) {
client.sendFrame(request, path);
for (size_t offset = 0; offset < content.size(); offset += DEVELOPMENT_FRAME_PAYLOAD_MAX) {
client.sendFrame(DEVELOPMENT_FRAME_DATA, content.substr(offset, DEVELOPMENT_FRAME_PAYLOAD_MAX));
}
client.sendFrame(DEVELOPMENT_FRAME_DONE, "");
DevelopmentFrameType type;
REQUIRE(client.readFrame(type, outPayload));
return type;
}
/** @return the frame type that ended the transfer: DONE, or FAIL with its message in @a outContent */
DevelopmentFrameType pull(TestClient& client, const std::string& path, std::string& outContent) {
client.sendFrame(DEVELOPMENT_FRAME_SYNC_PULL, path);
outContent.clear();
DevelopmentFrameType type;
std::string payload;
while (true) {
REQUIRE(client.readFrame(type, payload));
if (type == DEVELOPMENT_FRAME_FAIL) {
outContent = payload;
}
if (type != DEVELOPMENT_FRAME_DATA) {
return type;
}
outContent += payload;
}
}
/** @return the file type, with its size in @a outSize */
uint8_t stat(TestClient& client, const std::string& path, uint64_t& outSize) {
client.sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, path);
DevelopmentFrameType type;
std::string payload;
REQUIRE(client.readFrame(type, payload));
REQUIRE_EQ(type, DEVELOPMENT_FRAME_SYNC_STAT_RESULT);
REQUIRE_EQ(payload.size(), 9);
outSize = 0;
for (int i = 1; i < 9; i++) {
outSize = (outSize << 8) | static_cast<uint8_t>(payload[i]);
}
return static_cast<uint8_t>(payload[0]);
}
std::string makeContent(size_t size) {
std::string content(size, '\0');
for (size_t i = 0; i < size; i++) {
content[i] = static_cast<char>((i * 31 + i / 7) & 0xFF);
}
return content;
}
} // namespace
TEST_CASE("sync pushes, stats and pulls a file") {
ensureFixture();
auto client = openSync();
const std::string path = syncPath("created/parents/file.bin");
const std::string content = makeContent(100 * 1024 + 17);
std::string payload;
REQUIRE_EQ(push(*client, path, content, payload), DEVELOPMENT_FRAME_OK);
CHECK_EQ(readHostFile(path), content);
uint64_t size = 0;
CHECK_EQ(stat(*client, path, size), DEVELOPMENT_SYNC_FILE_REGULAR);
CHECK_EQ(size, content.size());
CHECK_EQ(stat(*client, syncPath("created"), size), DEVELOPMENT_SYNC_FILE_DIRECTORY);
CHECK_EQ(stat(*client, syncPath("missing"), size), DEVELOPMENT_SYNC_FILE_MISSING);
std::string pulled;
REQUIRE_EQ(pull(*client, path, pulled), DEVELOPMENT_FRAME_DONE);
CHECK_EQ(pulled, content);
// Overwrites, and handles empty files
REQUIRE_EQ(push(*client, path, "", payload), DEVELOPMENT_FRAME_OK);
REQUIRE_EQ(pull(*client, path, pulled), DEVELOPMENT_FRAME_DONE);
CHECK(pulled.empty());
}
TEST_CASE("sync reports failures and keeps the connection usable") {
ensureFixture();
auto client = openSync();
std::string payload;
std::string pulled;
REQUIRE_EQ(pull(*client, syncPath("missing"), pulled), DEVELOPMENT_FRAME_FAIL);
REQUIRE_EQ(pull(*client, "relative/path", pulled), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(pulled, "invalid path");
std::filesystem::create_directories(syncPath("directory"));
REQUIRE_EQ(pull(*client, syncPath("directory"), pulled), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(pulled, "is a directory");
REQUIRE_EQ(push(*client, syncPath("directory"), "data", payload), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(payload, "is a directory");
REQUIRE_EQ(push(*client, syncPath("after-failures.txt"), "still works", payload), DEVELOPMENT_FRAME_OK);
CHECK_EQ(readHostFile(syncPath("after-failures.txt")), "still works");
}
TEST_CASE("sync removes a partially pushed file when the client disconnects") {
ensureFixture();
const std::string path = syncPath("partial.bin");
{
auto client = openSync();
// The file exists before the interrupted push, so its removal is observable
std::string payload;
REQUIRE_EQ(push(*client, path, makeContent(500), payload), DEVELOPMENT_FRAME_OK);
client->sendFrame(DEVELOPMENT_FRAME_SYNC_PUSH, path);
client->sendFrame(DEVELOPMENT_FRAME_DATA, makeContent(300));
client->drop();
}
// The next session starts once the interrupted one ended
auto client = openSync();
CHECK_FALSE(std::filesystem::exists(path));
}
TEST_CASE("sync rejects an unexpected frame by closing the connection") {
ensureFixture();
auto client = openSync();
client->sendFrame(DEVELOPMENT_FRAME_RESIZE, "1234");
CHECK(client->waitForClose());
}
// endregion
// region App
namespace {
std::atomic<int> runCount { 0 };
int32_t runnableMain(int, char*[]) {
runCount++;
return 0;
}
const AppManifest runnableManifest = {
.id = "devtest.runnable",
.name = "Runnable",
.category = APP_CATEGORY_USER,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(runnableMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};
void ensureAppFixture() {
ensureFixture();
static bool added = false;
if (!added) {
added = true;
REQUIRE_EQ(app_manager_add(&runnableManifest), ERROR_NONE);
}
}
void addTarEntry(std::string& tar, const std::string& name, const std::string& content) {
char header[512] = {};
strncpy(header, name.c_str(), 99);
snprintf(header + 100, 8, "%07o", 0644);
snprintf(header + 108, 8, "%07o", 0);
snprintf(header + 116, 8, "%07o", 0);
snprintf(header + 124, 12, "%011o", static_cast<unsigned>(content.size()));
snprintf(header + 136, 12, "%011o", 0);
memset(header + 148, ' ', 8);
header[156] = '0';
memcpy(header + 257, "ustar", 6);
memcpy(header + 263, "00", 2);
unsigned checksum = 0;
for (char character : header) {
checksum += static_cast<unsigned char>(character);
}
snprintf(header + 148, 8, "%06o", checksum);
header[155] = ' ';
tar.append(header, sizeof(header));
tar += content;
tar.append((512 - content.size() % 512) % 512, '\0');
}
std::string makePackage() {
std::string tar;
addTarEntry(tar, "manifest.properties",
"manifest.version=0.3\n"
"id=devtest.package\n"
"version.name=1.0.0\n"
"version.code=1\n"
"target.sdk=" TT_VERSION "\n"
"app.0.id=devtest.package.app\n"
"app.0.name=Packaged\n"
"app.0.binary=app\n");
addTarEntry(tar, "app", "not a real binary");
tar.append(1024, '\0');
return tar;
}
std::unique_ptr<TestClient> openApp() {
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE;
std::string status;
auto client = greetWhenIdle(&identity(0), status, "app");
REQUIRE_EQ(status, ACCEPTED);
return client;
}
/** @return the answer type, with a FAIL message in @a outMessage */
DevelopmentFrameType request(TestClient& client, DevelopmentFrameType type, const std::string& payload, std::string& outMessage) {
client.sendFrame(type, payload);
DevelopmentFrameType answer;
REQUIRE(client.readFrame(answer, outMessage));
return answer;
}
std::string temporaryDirectory() {
char path[FILE_MAX_PATH_STRING_LENGTH];
REQUIRE_EQ(paths_get_temp_path(path, sizeof(path)), ERROR_NONE);
return path;
}
bool isDirectoryEmptyOrMissing(const std::string& path) {
return !std::filesystem::exists(path) || std::filesystem::is_empty(path);
}
} // namespace
TEST_CASE("app mode runs an app and reports unknown ones") {
ensureAppFixture();
auto client = openApp();
std::string message;
const int before = runCount;
REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_RUN, runnableManifest.id, message), DEVELOPMENT_FRAME_OK);
const TickType_t start = xTaskGetTickCount();
while (runCount == before && !isExpired(start, TIMEOUT_MS)) {
delay_millis(10);
}
CHECK_EQ(runCount, before + 1);
REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_RUN, "devtest.missing", message), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(message, "not found");
}
TEST_CASE("app mode installs and uninstalls a package") {
ensureAppFixture();
auto client = openApp();
std::string message;
REQUIRE_EQ(push(*client, "devtest.app", makePackage(), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_OK);
AppManifest manifest;
CHECK_EQ(app_manager_find_manifest("devtest.package.app", &manifest), ERROR_NONE);
CHECK(isDirectoryEmptyOrMissing(temporaryDirectory()));
REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.package", message), DEVELOPMENT_FRAME_OK);
CHECK_NE(app_manager_find_manifest("devtest.package.app", &manifest), ERROR_NONE);
// Uninstalling a package that isn't installed succeeds
REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.package", message), DEVELOPMENT_FRAME_OK);
}
TEST_CASE("app mode refuses invalid packages and file names") {
ensureAppFixture();
auto client = openApp();
std::string message;
REQUIRE_EQ(push(*client, "garbage.app", makeContent(3000), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(message, "invalid package");
CHECK(isDirectoryEmptyOrMissing(temporaryDirectory()));
REQUIRE_EQ(push(*client, "../escape.app", makePackage(), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_FAIL);
CHECK_EQ(message, "invalid file name");
}
TEST_CASE("each mode only accepts its own requests") {
ensureAppFixture();
{
auto client = openApp();
client->sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, "/");
CHECK(client->waitForClose());
}
auto client = openSync();
client->sendFrame(DEVELOPMENT_FRAME_APP_RUN, runnableManifest.id);
CHECK(client->waitForClose());
}
// endregion
TEST_CASE("trusted clients are listed with their name and can be removed") {
ensureFixture();
promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS;
std::string status;
auto client = greetWhenIdle(&identity(4), status, "sync");
REQUIRE_EQ(status, ACCEPTED);
client.reset();
const std::string fingerprint = identity(4).credentials.fingerprint;
struct Found {
const std::string& fingerprint;
std::string name;
bool found;
} found { fingerprint, {}, false };
REQUIRE_EQ(development_server_for_each_trusted_client([](const char* storedFingerprint, const char* name, void* context) {
auto* found = static_cast<Found*>(context);
if (found->fingerprint == storedFingerprint) {
found->found = true;
found->name = name;
}
return true;
}, &found), ERROR_NONE);
REQUIRE(found.found);
CHECK_EQ(found.name, "test");
REQUIRE_EQ(development_server_remove_trusted_client(fingerprint.c_str()), ERROR_NONE);
CHECK_EQ(development_server_remove_trusted_client(fingerprint.c_str()), ERROR_NOT_FOUND);
// The removed client is asked for again
promptResult = DEVELOPMENT_PROMPT_REJECT;
auto rejected = greetWhenIdle(&identity(4), status, "sync");
CHECK_EQ(status, REJECTED);
}