Storage: share the card with a phone's browser (#88)
CI / build (pull_request) Successful in 1m51s
Site / build (pull_request) Successful in 16s

`w` in the Storage App starts a small HTTP server and shows its address,
as a QR code and in letters, with a six-digit code. A browser on the same
network that has typed the code can list, download, upload, make folders
and delete, under the Storage App's rules. The server runs only while that
screen is open. Nothing is encrypted, and the screen says so.

Uploads are streamed to the card under a temporary name and renamed when
whole. Every access to the card is handed to the storage task, 8 KB at a
time, from the server's own task.

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 00:11:55 +02:00
co-authored by Claude Opus 5.5
parent c35bc47693
commit 6b02cd3d5f
16 changed files with 934 additions and 5 deletions
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "share_rules.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_what_a_request_asks_for() {
TEST_ASSERT_EQUAL_STRING("/notes/my list.txt", urlDecode("%2Fnotes%2Fmy%20list.txt").c_str());
TEST_ASSERT_EQUAL_STRING("a+b", urlDecode("a+b").c_str());
TEST_ASSERT_EQUAL_STRING("100%", urlDecode("100%").c_str());
TEST_ASSERT_EQUAL_STRING("%zz", urlDecode("%zz").c_str());
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", urlDecode("%C3%A9t%C3%a9").c_str());
std::string v;
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "path", v));
TEST_ASSERT_EQUAL_STRING("/notes", v.c_str());
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "replace", v));
TEST_ASSERT_EQUAL_STRING("1", v.c_str());
TEST_ASSERT_FALSE(queryParam("xpath=1&pathx=2", "path", v));
TEST_ASSERT_FALSE(queryParam("", "path", v));
TEST_ASSERT_TRUE(queryParam("a=&path=", "path", v));
TEST_ASSERT_EQUAL_STRING("", v.c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("theme=dark; s=abc123; x=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("s=abc123", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("ss=abc123; xs=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("", "s").c_str());
}
void test_paths_a_browser_may_name() {
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/notes/my list (2).txt").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/gemini/saved/\xC3\xA9t\xC3\xA9.gmi").c_str());
for (const char* bad : {"", "notes", "/notes/", "/notes/../wifi", "/..", "/a//b", "/a/./b", "/a\\b", "/a/b\n", "/a:b", "/what?", "/a*"})
TEST_ASSERT_TRUE_MESSAGE(!checkSharePath(bad).empty(), bad);
TEST_ASSERT_TRUE(!checkSharePath("/" + std::string(300, 'a')).empty());
}
void test_json() {
TEST_ASSERT_EQUAL_STRING("\"plain\"", jsonString("plain").c_str());
TEST_ASSERT_EQUAL_STRING("\"a \\\"b\\\" \\\\ c\"", jsonString("a \"b\" \\ c").c_str());
TEST_ASSERT_EQUAL_STRING("\"tab\\u0009\"", jsonString("tab\t").c_str());
TEST_ASSERT_EQUAL_STRING("\"\xC3\xA9\"", jsonString("\xC3\xA9").c_str());
ShareListing empty("/");
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/\",\"items\":[],\"more\":false}", empty.json(false).c_str());
ShareListing l("/notes");
l.add("a \"b\".txt", 12, false, 1791400000);
l.add("old", 0, true, 0);
TEST_ASSERT_EQUAL_size_t(2, l.count());
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/notes\",\"items\":[{\"n\":\"a \\\"b\\\".txt\",\"s\":12,\"d\":0,\"t\":1791400000},{\"n\":\"old\",\"s\":0,\"d\":1,\"t\":0}],\"more\":true}",
l.json(true).c_str());
}
void test_the_code_and_the_token() {
ShareAuth auth;
std::string token;
const uint8_t r16[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff};
TEST_ASSERT_TRUE(auth.login("000000", 0, r16, token) == ShareAuth::Result::Locked); // not started: nobody gets in
TEST_ASSERT_FALSE(auth.allowed(""));
const uint8_t r4[4] = {0x00, 0x00, 0x30, 0x39}; // 12345
auth.begin(r4);
TEST_ASSERT_EQUAL_STRING("012345", auth.code().c_str());
TEST_ASSERT_FALSE(auth.allowed(""));
TEST_ASSERT_TRUE(auth.login("12345", 1000, r16, token) == ShareAuth::Result::Wrong); // the leading zero counts
TEST_ASSERT_TRUE(token.empty());
TEST_ASSERT_TRUE(auth.login("012 345", 2000, r16, token) == ShareAuth::Result::Ok); // typed as the screen groups it
TEST_ASSERT_EQUAL_STRING("00112233445566778899aabbccddeeff", token.c_str());
TEST_ASSERT_TRUE(auth.allowed(token));
TEST_ASSERT_FALSE(auth.allowed(token + "0"));
TEST_ASSERT_FALSE(auth.allowed("00112233445566778899aabbccddeef0"));
// Sharing started again: a new code, and yesterday's browser is out.
const uint8_t again[4] = {0xFF, 0xFF, 0xFF, 0xFF};
auth.begin(again);
TEST_ASSERT_EQUAL_size_t(6, auth.code().size());
TEST_ASSERT_FALSE(auth.allowed(token));
}
void test_five_wrong_codes_close_it_for_a_minute() {
ShareAuth auth;
const uint8_t r4[4] = {0, 0, 0, 7};
const uint8_t r16[16] = {0};
auth.begin(r4);
std::string token;
for (int i = 0; i < 4; i++) TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Wrong);
TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Locked);
TEST_ASSERT_TRUE(auth.login("000007", 30000, r16, token) == ShareAuth::Result::Locked); // the right one too
TEST_ASSERT_TRUE(auth.login("000007", 62000, r16, token) == ShareAuth::Result::Ok);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_what_a_request_asks_for);
RUN_TEST(test_paths_a_browser_may_name);
RUN_TEST(test_json);
RUN_TEST(test_the_code_and_the_token);
RUN_TEST(test_five_wrong_codes_close_it_for_a_minute);
return UNITY_END();
}