From f7272aced8aa8d72c422218e2b71b99435e5ef57 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:35:24 +0000 Subject: [PATCH 01/19] test: capture server regressions --- tests/test_server.py | 274 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 tests/test_server.py diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..e8ad745 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,274 @@ +"""Run with MAPLE_BINARY pointing to the executable under test.""" + +import os +import pathlib +import shutil +import signal +import socket +import ssl +import subprocess +import tempfile +import time +import unittest + +CONFIGURATION_TIMEOUT_SECONDS = 0.5 +CONNECTION_TIMEOUT_SECONDS = 2 +DEFAULT_PORT = 1965 +IDLE_CONNECTION_TIMEOUT_SECONDS = 1 +PROCESS_EXIT_TIMEOUT_SECONDS = 2 +RECEIVE_BUFFER_BYTES = 65536 +REQUEST_EXPIRY_TIMEOUT_SECONDS = 7 +REQUEST_FRAGMENT_DELAY_SECONDS = 0.02 +RESPONSE_PREFIX_BYTES = 4096 +SERVER_SETTLE_SECONDS = 0.1 +STARTUP_POLL_INTERVAL_SECONDS = 0.01 +STARTUP_PROBE_TIMEOUT_SECONDS = 0.1 +STARTUP_TIMEOUT_SECONDS = 3 + +class ServerTests(unittest.TestCase): + @classmethod + def setUpClass(test_class): + test_class.binary = pathlib.Path(os.environ.get('MAPLE_BINARY', 'build/maple')).resolve() + test_class.certificates = tempfile.TemporaryDirectory(prefix='maple-certificates-') + test_class.certificate_directory = pathlib.Path(test_class.certificates.name) + + subprocess.run( + ['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', + 'ec_paramgen_curve:prime256v1', '-nodes', '-days', '1', + '-subj', '/CN=localhost', '-keyout', str(test_class.certificate_directory / 'private.pem'), + '-out', str(test_class.certificate_directory / 'public.pem')], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + test_class.context = ssl._create_unverified_context() + + @classmethod + def tearDownClass(test_class): + test_class.certificates.cleanup() + + def setUp(self): + self.workspace = tempfile.TemporaryDirectory(prefix='maple-server-') + + self.addCleanup(self.workspace.cleanup) + + self.directory = pathlib.Path(self.workspace.name) + self.content = self.directory / '.maple/gmi' + + self.content.mkdir(parents=True) + (self.content / 'index.gmi').write_bytes(b'INDEX') + (self.content / 'existing.gmi').write_bytes(b'ORIGINAL') + (self.directory / 'outside').mkdir() + (self.directory / 'outside/index.gmi').write_bytes(b'OUTSIDE') + + for name in ('public.pem', 'private.pem'): + shutil.copyfile(self.certificate_directory / name, self.directory / '.maple' / name) + + self.process = None + + self.addCleanup(self.stop) + + def start(self, **settings): + with socket.socket() as reservation: + reservation.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + reservation.bind(('127.0.0.1', DEFAULT_PORT)) + + environment = {key: value for key, value in os.environ.items() if not key.startswith('TITAN')} + + environment.update(TITAN='1', TITAN_MAX_SIZE='8192') + environment.update(settings) + + self.process = subprocess.Popen( + [str(self.binary)], cwd=self.directory, env=environment, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + startup_deadline_seconds = time.monotonic() + STARTUP_TIMEOUT_SECONDS + + while time.monotonic() < startup_deadline_seconds: + if self.process.poll() is not None: + self.fail(f'Server exited at startup: {self.process.returncode}') + + try: + with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=STARTUP_PROBE_TIMEOUT_SECONDS): + return + except ConnectionRefusedError: + time.sleep(STARTUP_POLL_INTERVAL_SECONDS) + + self.fail('Server did not start') + + def stop(self): + if self.process is not None: + if self.process.poll() is None: + self.process.terminate() + + try: + self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + + def connect(self, timeout_seconds=CONNECTION_TIMEOUT_SECONDS): + connection = socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=timeout_seconds) + + try: + return self.context.wrap_socket(connection, server_hostname='localhost') + except BaseException: + connection.close() + + raise + + def request(self, *parts): + with self.connect() as connection: + for part in parts: + connection.sendall(part) + time.sleep(REQUEST_FRAGMENT_DELAY_SECONDS) + + response = bytearray() + + while True: + chunk = connection.recv(RECEIVE_BUFFER_BYTES) + + if not chunk: + return bytes(response) + + response.extend(chunk) + + def upload(self, path, body, declared_size_bytes=None, token=''): + declared_size_bytes = len(body) if declared_size_bytes is None else declared_size_bytes + header = f'titan://localhost{path};mime=text/gemini;size={declared_size_bytes}' + + if token: + header += ';token=' + token + + return self.request(header.encode() + b'\r\n' + body) + + def test_existing_file(self): + self.start() + self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), + b'20 text/gemini\r\nORIGINAL') + + @unittest.expectedFailure + def test_upload_traversal(self): + self.start() + + response = self.upload('/../../outside/index.gmi', b'CHANGED') + + self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') + self.assertTrue(response.startswith(b'50')) + + @unittest.expectedFailure + def test_index_traversal(self): + self.start() + self.assertNotIn(b'OUTSIDE', self.request(b'gemini://localhost/../../outside/\r\n')) + + @unittest.expectedFailure + def test_symlinks(self): + (self.content / 'linked.gmi').symlink_to(self.directory / 'outside/index.gmi') + (self.content / 'linked').symlink_to(self.directory / 'outside', target_is_directory=True) + self.start() + self.assertNotIn(b'OUTSIDE', self.request(b'gemini://localhost/linked.gmi\r\n')) + self.assertNotIn(b'OUTSIDE', self.request(b'gemini://localhost/linked/\r\n')) + self.upload('/linked.gmi', b'CHANGED') + self.upload('/linked/index.gmi', b'CHANGED') + self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') + + @unittest.expectedFailure + def test_new_upload_is_visible(self): + self.start() + self.upload('/new.gmi', b'NEW') + self.assertEqual(self.request(b'gemini://localhost/new.gmi\r\n'), b'20 text/gemini\r\nNEW') + + @unittest.expectedFailure + def test_missing_index(self): + self.start() + self.assertTrue(self.request(b'gemini://localhost/missing/\r\n').startswith(b'51')) + + @unittest.expectedFailure + def test_malformed_titan_keeps_server_alive(self): + self.start() + + with self.connect() as connection: + connection.sendall(b'titan://localhost/existing.gmi;size=3') + + time.sleep(SERVER_SETTLE_SECONDS) + self.assertIsNone(self.process.poll()) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + self.assertTrue(self.request(b'gemini://localhost/\r\n').startswith(b'20')) + + @unittest.expectedFailure + def test_strict_upload_size(self): + self.start(TITAN_MAX_SIZE='4') + + for declared_size_bytes in ('0', '1junk', '-1', '99999999999999999999999999'): + with self.subTest(size=declared_size_bytes): + response = self.upload('/existing.gmi', b'OVERSIZED', declared_size_bytes=declared_size_bytes) + + self.assertTrue(response.startswith(b'59')) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + @unittest.expectedFailure + def test_negative_configured_size(self): + environment = {**os.environ, 'TITAN': '1', 'TITAN_MAX_SIZE': '-1'} + result = subprocess.run([str(self.binary)], cwd=self.directory, env=environment, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=CONFIGURATION_TIMEOUT_SECONDS) + + self.assertNotEqual(result.returncode, 0) + + @unittest.expectedFailure + def test_fragmented_request_and_body(self): + self.start() + self.assertEqual(self.request(b'gemini://local', b'host/existing.gmi\r', b'\n'), + b'20 text/gemini\r\nORIGINAL') + + response = self.request(b'titan://localhost/new.gmi;mime=text/gemini;size=4\r\n', b'DA', b'TA') + + self.assertTrue(response.startswith(b'20')) + self.assertEqual((self.content / 'new.gmi').read_bytes(), b'DATA') + + @unittest.expectedFailure + def test_incomplete_upload_preserves_file(self): + self.start() + + with self.connect() as connection: + connection.sendall(b'titan://localhost/existing.gmi;mime=text/gemini;size=4\r\na') + time.sleep(SERVER_SETTLE_SECONDS) + + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + @unittest.expectedFailure + def test_large_upload(self): + body_size_bytes = 64 * 1024 + body = b'X' * body_size_bytes + + self.start(TITAN_MAX_SIZE=str(body_size_bytes)) + self.assertTrue(self.upload('/large.gmi', body).startswith(b'20')) + self.assertEqual((self.content / 'large.gmi').read_bytes(), body) + + @unittest.expectedFailure + def test_wrong_token(self): + self.start(TITAN_TOKEN='expected') + self.assertTrue(self.upload('/existing.gmi', b'BAD', token='wrong').startswith(b'59')) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + @unittest.expectedFailure + def test_failed_write(self): + self.start() + self.assertTrue(self.upload('/missing/file.gmi', b'DATA').startswith(b'50')) + + @unittest.expectedFailure + def test_idle_handshake_expires(self): + self.start() + + with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): + with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'gemini://localhost/\r\n') + self.assertIn(b'INDEX', connection.recv(RESPONSE_PREFIX_BYTES)) + + @unittest.expectedFailure + def test_clean_shutdown(self): + self.start() + self.process.send_signal(signal.SIGINT) + self.assertEqual(self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS), 0) + +if __name__ == '__main__': + unittest.main() From 21052d5502c7b1a70e80b1279fa93960c09e345d Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:35:28 +0000 Subject: [PATCH 02/19] fix: confine content access and remove stale file catalogue --- build.ninja | 3 +- maple/content.cc | 185 +++++++++++++++++++++++++++++++++++++++++++ maple/content.hh | 13 +++ maple/gemini.cc | 34 +++----- maple/gemini.hh | 6 +- maple/maple.cc | 30 +------ maple/titan.cc | 11 ++- tests/test_server.py | 6 -- 8 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 maple/content.cc create mode 100644 maple/content.hh diff --git a/build.ninja b/build.ninja index 861ce91..f8f8b48 100644 --- a/build.ninja +++ b/build.ninja @@ -13,8 +13,9 @@ rule link build $outdir/$name.o: compile $srcdir/$name.cc build $outdir/gemini.o: compile $srcdir/gemini.cc +build $outdir/content.o: compile $srcdir/content.cc build $outdir/titan.o: compile $srcdir/titan.cc -build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o +build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o $outdir/content.o default $outdir/$name diff --git a/maple/content.cc b/maple/content.cc new file mode 100644 index 0000000..688c9a6 --- /dev/null +++ b/maple/content.cc @@ -0,0 +1,185 @@ +#include "content.hh" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +class Descriptor { +public: + explicit Descriptor(int value) : value_(value) {} + + Descriptor(const Descriptor &) = delete; + auto operator=(const Descriptor &) -> Descriptor & = delete; + + Descriptor(Descriptor &&other) noexcept + : value_(std::exchange(other.value_, -1)) {} + + auto operator=(Descriptor &&other) noexcept -> Descriptor & { + if (this != &other) { + if (value_ >= 0) { + close(value_); + } + + value_ = std::exchange(other.value_, -1); + } + + return *this; + } + + ~Descriptor() { + if (value_ >= 0) { + close(value_); + } + } + + auto get() const -> int { return value_; } + +private: + int value_; +}; + +struct Destination { + Descriptor directory; + std::string name; +}; + +auto resolve(std::string_view path) -> std::optional { + if (path.empty() || path.front() != '/' || + path.find('\0') != std::string_view::npos) { + return std::nullopt; + } + + std::string relative_path(path.substr(1)); + + if (relative_path.empty() || relative_path.back() == '/') { + relative_path += "index.gmi"; + } + + Descriptor directory(open(".maple/gmi", O_RDONLY | O_DIRECTORY | O_NOFOLLOW)); + std::size_t component_start_bytes = 0; + + if (directory.get() < 0) { + return std::nullopt; + } + + for (;;) { + const std::size_t separator_offset_bytes = + relative_path.find('/', component_start_bytes); + const std::string component = relative_path.substr( + component_start_bytes, separator_offset_bytes - component_start_bytes); + + if (component.empty() || component == "." || component == "..") { + return std::nullopt; + } + + if (separator_offset_bytes == std::string::npos) { + const std::size_t extension_start_bytes = component.find_last_of('.'); + std::string extension = extension_start_bytes == std::string::npos + ? "" + : component.substr(extension_start_bytes); + + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + + if (extension != ".gmi") { + return std::nullopt; + } + + return Destination{std::move(directory), component}; + } + + const int child = openat(directory.get(), component.c_str(), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + + if (child < 0) { + return std::nullopt; + } + + directory = Descriptor(child); + component_start_bytes = separator_offset_bytes + 1; + } +} +} // namespace + +namespace maple::content { +auto read(std::string_view path) -> std::optional { + auto destination = resolve(path); + + if (!destination) { + return std::nullopt; + } + + Descriptor file(openat(destination->directory.get(), + destination->name.c_str(), + O_RDONLY | O_NOFOLLOW | O_NONBLOCK)); + struct stat status{}; + + if (file.get() < 0 || fstat(file.get(), &status) < 0 || + !S_ISREG(status.st_mode)) { + return std::nullopt; + } + + constexpr std::size_t buffer_capacity_bytes = 8192; + std::string body; + std::array buffer{}; + + for (;;) { + const ssize_t byte_count = ::read(file.get(), buffer.data(), buffer.size()); + + if (byte_count < 0) { + if (errno == EINTR) { + continue; + } + + return std::nullopt; + } + + if (byte_count == 0) { + return body; + } + + body.append(buffer.data(), static_cast(byte_count)); + } +} + +auto write(std::string_view path, std::string_view body) -> bool { + auto destination = resolve(path); + + if (!destination) { + return false; + } + + Descriptor file(openat(destination->directory.get(), + destination->name.c_str(), + O_WRONLY | O_CREAT | O_NOFOLLOW | O_NONBLOCK, 0600)); + struct stat status{}; + + if (file.get() < 0 || fstat(file.get(), &status) < 0 || + !S_ISREG(status.st_mode) || ftruncate(file.get(), 0) < 0) { + return false; + } + + while (!body.empty()) { + const ssize_t byte_count = ::write(file.get(), body.data(), body.size()); + + if (byte_count < 0 && errno == EINTR) { + continue; + } + + if (byte_count <= 0) { + return false; + } + + body.remove_prefix(static_cast(byte_count)); + } + + return true; +} +} // namespace maple::content diff --git a/maple/content.hh b/maple/content.hh new file mode 100644 index 0000000..e62ca3e --- /dev/null +++ b/maple/content.hh @@ -0,0 +1,13 @@ +#ifndef MAPLE_CONTENT_HH +#define MAPLE_CONTENT_HH + +#include +#include +#include + +namespace maple::content { +auto read(std::string_view path) -> std::optional; +auto write(std::string_view path, std::string_view body) -> bool; +} // namespace maple::content + +#endif diff --git a/maple/gemini.cc b/maple/gemini.cc index 010e844..a4d5822 100644 --- a/maple/gemini.cc +++ b/maple/gemini.cc @@ -18,34 +18,20 @@ * SPDX-License-Identifier: GPL-3.0-only */ -#include -#include -#include -#include -#include -#include - #include "gemini.hh" +#include "content.hh" namespace maple::gemini { -auto handle_client(const std::vector &gemini_files, - std::string path, std::stringstream &response) -> void { - // Check if the route is a file being served - if (std::find(gemini_files.begin(), gemini_files.end(), - ".maple/gmi" + path) != gemini_files.end()) { - // If the route is a file being served; get the file contents - response << "20 text/gemini\r\n" - << std::ifstream(".maple/gmi" + path).rdbuf(); - } else { - if (path.empty() || path.at(path.length() - 1) == '/') { - response << "20 text/gemini\r\n" - << std::ifstream(".maple/gmi" + path + "index.gmi").rdbuf(); - } else { - response - << "51 The server (Maple) could not find the specified file.\r\n"; - } +auto handle_client(const std::string &path, std::stringstream &response) + -> void { + const auto body = content::read(path); + + if (!body) { + response << "51 File not found.\r\n"; + + return; } - std::cout << "requested " << path << '\n'; + response << "20 text/gemini\r\n" << *body; } } // namespace maple::gemini diff --git a/maple/gemini.hh b/maple/gemini.hh index 8a7796c..a4a4a90 100644 --- a/maple/gemini.hh +++ b/maple/gemini.hh @@ -22,12 +22,12 @@ #define GEMINI_HH #include -#include +#include namespace maple { namespace gemini { -auto handle_client(const std::vector &, std::string, - std::stringstream &) -> void; +auto handle_client(const std::string &path, std::stringstream &response) + -> void; } } // namespace maple diff --git a/maple/maple.cc b/maple/maple.cc index 426cc14..4da8176 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -25,7 +25,6 @@ #include #include #include -#include #include #include #include @@ -34,7 +33,6 @@ #include #include #include -#include #if !defined(__APPLE__) #include @@ -45,11 +43,9 @@ #include "titan.hh" auto main() -> int { - std::vector gemini_files; bool titan = false; std::string titan_token; std::size_t titan_max_size = 0; - const std::string GEMINI_FILE_EXTENSION = "gmi"; // Check if the user is want to support Titan and set it up if (maple::setup_environment(titan, titan_token, titan_max_size) == 1) { @@ -59,34 +55,11 @@ auto main() -> int { // Try a graceful shutdown when a SIGINT is detected signal(SIGINT, [](int _signal) -> void { std::cout << "shutdown(" << _signal << ")\n"; - close(maple::maple_socket); SSL_CTX_free(maple::ssl_context); }); signal(SIGPIPE, SIG_IGN); - // Find and keep track of all Gemini files to serve - for (const std::filesystem::directory_entry &entry : - std::filesystem::recursive_directory_iterator(".maple/gmi")) { - std::string file_extension = entry.path().string().substr( - entry.path().string().find_last_of('.') + 1); - - // Only keep track of file if it is a Gemini file - if (std::equal(file_extension.begin(), file_extension.end(), - GEMINI_FILE_EXTENSION.begin(), GEMINI_FILE_EXTENSION.end(), - [](char character_a, char character_b) -> bool { - return std::tolower(character_a) == - std::tolower(character_b); - })) { - gemini_files.push_back(entry.path()); - } - } - - // Inform user of which files will be served - for (const std::string &file : gemini_files) { - std::cout << "serving " << file << '\n'; - } - // Setup SSL if (maple::setup_ssl() == EXIT_FAILURE) { return EXIT_FAILURE; @@ -179,7 +152,7 @@ auto main() -> int { // Gemini if (request_scheme == 1) { - maple::gemini::handle_client(gemini_files, path, response); + maple::gemini::handle_client(path, response); } else { // Titan if (!titan) { response @@ -300,7 +273,6 @@ auto setup_ssl() -> int { socket_address.sin_family = AF_INET; socket_address.sin_port = htons(GEMINI_PORT); socket_address.sin_addr.s_addr = htonl(INADDR_ANY); - maple::maple_socket = socket(AF_INET, SOCK_STREAM, 0); if (maple::maple_socket < 0) { diff --git a/maple/titan.cc b/maple/titan.cc index 2021903..90651ee 100644 --- a/maple/titan.cc +++ b/maple/titan.cc @@ -24,8 +24,8 @@ #include #include #include - #include "titan.hh" +#include "content.hh" namespace maple::titan { auto parameters_to_map(const std::vector ¶meters) @@ -55,12 +55,14 @@ auto handle_client(std::stringstream &response, std::string path, const std::string &titan_token, std::size_t titan_max_size) -> void { std::vector parameters; + // Find path in `path` std::size_t delimiter_position = path.find(';'); std::string update_path = path.substr(0, delimiter_position); const std::string body = path.substr(path.find('\n') + 1, path.length() - 1); path.erase(path.find('\n') - 1, path.length() - 1); + // parameters.push_back(update_path); path.erase(0, delimiter_position + 1); // Remove path from `path` @@ -108,6 +110,7 @@ auto handle_client(std::stringstream &response, std::string path, if (parameters_map.find("mime") == parameters_map.end()) { response << "20 text/gemini\r\nThe serve (Maple) did not " "receive a mime parameter!"; + break; } @@ -153,9 +156,11 @@ auto handle_client(std::stringstream &response, std::string path, token_iterator == parameters_map.end() ? "" : token_iterator->second; if (token == titan_token) { - std::ofstream file(".maple/gmi" + update_path); + if (!content::write(update_path, body)) { + response << "50 Unable to write content.\r\n"; - file << body; + return; + } response << "20 text/gemini\r\nSuccessfully wrote " << body.length() << " bytes to " << update_path << '!'; diff --git a/tests/test_server.py b/tests/test_server.py index e8ad745..4360dc2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -146,7 +146,6 @@ def test_existing_file(self): self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), b'20 text/gemini\r\nORIGINAL') - @unittest.expectedFailure def test_upload_traversal(self): self.start() @@ -155,12 +154,10 @@ def test_upload_traversal(self): self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') self.assertTrue(response.startswith(b'50')) - @unittest.expectedFailure def test_index_traversal(self): self.start() self.assertNotIn(b'OUTSIDE', self.request(b'gemini://localhost/../../outside/\r\n')) - @unittest.expectedFailure def test_symlinks(self): (self.content / 'linked.gmi').symlink_to(self.directory / 'outside/index.gmi') (self.content / 'linked').symlink_to(self.directory / 'outside', target_is_directory=True) @@ -171,13 +168,11 @@ def test_symlinks(self): self.upload('/linked/index.gmi', b'CHANGED') self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') - @unittest.expectedFailure def test_new_upload_is_visible(self): self.start() self.upload('/new.gmi', b'NEW') self.assertEqual(self.request(b'gemini://localhost/new.gmi\r\n'), b'20 text/gemini\r\nNEW') - @unittest.expectedFailure def test_missing_index(self): self.start() self.assertTrue(self.request(b'gemini://localhost/missing/\r\n').startswith(b'51')) @@ -250,7 +245,6 @@ def test_wrong_token(self): self.assertTrue(self.upload('/existing.gmi', b'BAD', token='wrong').startswith(b'59')) self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') - @unittest.expectedFailure def test_failed_write(self): self.start() self.assertTrue(self.upload('/missing/file.gmi', b'DATA').startswith(b'50')) From 3c7304b6d8143ec7bb5857387f5505bbf359a1e2 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:37:02 +0000 Subject: [PATCH 03/19] fix: validate Titan parameters and upload sizes strictly --- maple/maple.cc | 31 +++---- maple/size.hh | 28 +++++++ maple/titan.cc | 188 +++++++++++++++---------------------------- maple/titan.hh | 28 ++++--- tests/test_server.py | 5 -- 5 files changed, 128 insertions(+), 152 deletions(-) create mode 100644 maple/size.hh diff --git a/maple/maple.cc b/maple/maple.cc index 4da8176..296a6b6 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -41,14 +41,15 @@ #include "gemini.hh" #include "maple.hh" #include "titan.hh" +#include "size.hh" auto main() -> int { bool titan = false; std::string titan_token; - std::size_t titan_max_size = 0; + std::size_t maximum_upload_bytes = 0; // Check if the user is want to support Titan and set it up - if (maple::setup_environment(titan, titan_token, titan_max_size) == 1) { + if (maple::setup_environment(titan, titan_token, maximum_upload_bytes) == 1) { return EXIT_FAILURE; } @@ -160,7 +161,7 @@ auto main() -> int { "Titan support enabled!"; } else { maple::titan::handle_client(response, path, titan_token, - titan_max_size); + maximum_upload_bytes); } } @@ -199,7 +200,7 @@ auto prepare_exit_with(const char *message, bool ssl) -> int { } auto setup_environment(bool &titan, std::string &titan_token, - std::size_t &titan_max_size) -> int { + std::size_t &maximum_upload_bytes) -> int { char *titan_environment = std::getenv("TITAN"); if (titan_environment == nullptr) { @@ -214,7 +215,7 @@ auto setup_environment(bool &titan, std::string &titan_token, if (valid_titan_environment == "true" || valid_titan_environment == "1") { char *unvalidated_titan_token = std::getenv("TITAN_TOKEN"); - char *unvalidated_titan_max_size = std::getenv("TITAN_MAX_SIZE"); + char *configured_maximum_upload_bytes = std::getenv("TITAN_MAX_SIZE"); if (unvalidated_titan_token == nullptr) { titan_token = ""; @@ -222,20 +223,22 @@ auto setup_environment(bool &titan, std::string &titan_token, titan_token = std::string(unvalidated_titan_token); } - if (unvalidated_titan_max_size == nullptr) { - constexpr std::size_t TITAN_MAX_SIZE = 1024; + if (configured_maximum_upload_bytes == nullptr) { + constexpr std::size_t default_maximum_upload_bytes = 1024; - titan_max_size = TITAN_MAX_SIZE; + maximum_upload_bytes = default_maximum_upload_bytes; std::cout << "no TITAN_MAX_SIZE set, defaulting to 1024\n"; } else { - try { - titan_max_size = - static_cast(std::stoi(unvalidated_titan_max_size)); - } catch (...) { - return maple::prepare_exit_with( - "TITAN_MAX_SIZE could not be interpreted as an integer", false); + const auto parsed_maximum_upload_bytes = maple::parse_size(configured_maximum_upload_bytes); + + if (!parsed_maximum_upload_bytes) { + std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n"; + + return EXIT_FAILURE; } + + maximum_upload_bytes = *parsed_maximum_upload_bytes; } titan = true; diff --git a/maple/size.hh b/maple/size.hh new file mode 100644 index 0000000..67e482e --- /dev/null +++ b/maple/size.hh @@ -0,0 +1,28 @@ +#ifndef MAPLE_SIZE_HH +#define MAPLE_SIZE_HH + +#include +#include +#include +#include + +namespace maple { +inline auto parse_size(std::string_view text) -> std::optional { + if (text.empty()) { + return std::nullopt; + } + + const char *text_end = text.data() + text.size(); + std::size_t value = 0; + const auto [parsed_end, error] = + std::from_chars(text.data(), text_end, value); + + if (error != std::errc{} || parsed_end != text_end) { + return std::nullopt; + } + + return value; +} +} // namespace maple + +#endif diff --git a/maple/titan.cc b/maple/titan.cc index 90651ee..3a5065a 100644 --- a/maple/titan.cc +++ b/maple/titan.cc @@ -18,158 +18,104 @@ * SPDX-License-Identifier: GPL-3.0-only */ -#include -#include -#include -#include -#include -#include #include "titan.hh" #include "content.hh" +#include "size.hh" namespace maple::titan { -auto parameters_to_map(const std::vector ¶meters) - -> std::map { - std::map parameters_map; +auto parse_upload(std::string_view header, std::string_view token, + std::size_t maximum_upload_bytes) -> std::optional { + const std::size_t separator_offset_bytes = header.find(';'); - for (const auto ¶meter : parameters) { - // Find the key in `parameter` - const std::size_t parameter_delimiter_position = parameter.find('='); - - if (parameter_delimiter_position == std::string::npos) { - continue; - } - - const std::string key = parameter.substr(0, parameter_delimiter_position); - const std::string value = - parameter.substr(parameter_delimiter_position + 1); - - // Add the key and value to `parameters_map` - parameters_map.emplace(key, value); + if (separator_offset_bytes == std::string_view::npos || + separator_offset_bytes == 0) { + return std::nullopt; } - return parameters_map; -} - -auto handle_client(std::stringstream &response, std::string path, - const std::string &titan_token, std::size_t titan_max_size) - -> void { - std::vector parameters; - - // Find path in `path` - std::size_t delimiter_position = path.find(';'); - std::string update_path = path.substr(0, delimiter_position); - const std::string body = path.substr(path.find('\n') + 1, path.length() - 1); - - path.erase(path.find('\n') - 1, path.length() - 1); + const std::string path(header.substr(0, separator_offset_bytes)); + std::optional size_bytes; + std::optional mime; + std::optional supplied_token; - // parameters.push_back(update_path); - path.erase(0, delimiter_position + 1); // Remove path from `path` + header.remove_prefix(separator_offset_bytes + 1); - // Find mime parameter in `path` - delimiter_position = path.find(';'); + while (!header.empty()) { + const std::size_t next_parameter_offset_bytes = header.find(';'); + const std::string_view parameter = + header.substr(0, next_parameter_offset_bytes); + const std::size_t value_separator_offset_bytes = parameter.find('='); - parameters.push_back(path.substr(0, delimiter_position)); - path.erase(0, delimiter_position + 1); // Remove mime parameter from `path` - - // Find size parameter in `path` - delimiter_position = path.find(';'); - - parameters.push_back(path.substr(0, delimiter_position)); + if (value_separator_offset_bytes == std::string_view::npos) { + return std::nullopt; + } - // Find token parameter in `path` - delimiter_position = path.find(';'); + const std::string_view key = + parameter.substr(0, value_separator_offset_bytes); + const std::string_view value = + parameter.substr(value_separator_offset_bytes + 1); - // Since the token is optional, only get and assign the token - // parameters value if it exists. - if (delimiter_position != std::string::npos) { - parameters.push_back( - path.substr(delimiter_position + 1, path.length() - 1)); - } + if (key == "size" && !size_bytes) { + size_bytes = parse_size(value); - /// Check if a parameter exists within a `std::vector` of Titan - /// parameters. - /* auto parameter_exists = []( - const std::vector &_parameters, - const std::string ¶meter - ) -> bool { - return std::any_of( - _parameters.begin(), - _parameters.end(), - [&](const std::string &s) -> bool { - return s.find(parameter) != std::string::npos; + if (!size_bytes || *size_bytes > maximum_upload_bytes) { + return std::nullopt; } - ); - }; */ - - std::map parameters_map = - maple::titan::parameters_to_map(parameters); - - // Make sure all tokens have been supplied - for (;;) { - if (parameters_map.find("mime") == parameters_map.end()) { - response << "20 text/gemini\r\nThe serve (Maple) did not " - "receive a mime parameter!"; - - break; + } else if (key == "mime" && !mime) { + mime = value; + } else if (key == "token" && !supplied_token) { + supplied_token = value; + } else { + return std::nullopt; } - if (parameters_map.find("size") == parameters_map.end()) { - response << "20 text/gemini\r\nThe serve (Maple) did not " - "receive a size parameter!"; - + if (next_parameter_offset_bytes == std::string_view::npos) { break; } - if (!titan_token.empty() && - parameters_map.find("token") == parameters_map.end()) { - response << "20 text/gemini\r\nThe serve (Maple) did not " - "receive a token parameter!"; + header.remove_prefix(next_parameter_offset_bytes + 1); - break; + if (header.empty()) { + return std::nullopt; } + } - try { - const std::size_t body_size = - static_cast(std::stoi(parameters_map.at("size"))); + if (!size_bytes || !mime || *mime != "text/gemini" || + supplied_token.value_or("") != token) { + return std::nullopt; + } - if (body_size > titan_max_size) { - response << "20 text/gemini\r\nThe server (Maple) received a body " - << "which is larger than the maximum allowed body size (" - << titan_max_size << ")."; + return Upload{path, *size_bytes}; +} - break; - } - } catch (...) { - response << "20 text/gemini\r\nThe server (Maple) could not interpret " - "the size parameter as an integer!"; +auto handle_client(std::stringstream &response, std::string_view request, + std::string_view token, std::size_t maximum_upload_bytes) + -> void { + constexpr std::size_t header_terminator_bytes = 2; + const std::size_t header_end_bytes = request.find("\r\n"); - break; - } + if (header_end_bytes == std::string_view::npos) { + response << "59 Incomplete Titan header.\r\n"; - if (update_path == "/") { - update_path = "/index.gmi"; - } + return; + } - const auto token_iterator = parameters_map.find("token"); - const std::string token = - token_iterator == parameters_map.end() ? "" : token_iterator->second; + const auto upload = parse_upload(request.substr(0, header_end_bytes), token, + maximum_upload_bytes); + const std::string_view body = + request.substr(header_end_bytes + header_terminator_bytes); - if (token == titan_token) { - if (!content::write(update_path, body)) { - response << "50 Unable to write content.\r\n"; + if (!upload || body.size() != upload->size_bytes) { + response << "59 Invalid Titan request.\r\n"; - return; - } + return; + } - response << "20 text/gemini\r\nSuccessfully wrote " << body.length() - << " bytes to " << update_path << '!'; - } else { - response << "20 text/gemini\r\nThe server (Maple) wrote to " - << update_path; - } + if (!content::write(upload->path, body)) { + response << "50 Unable to write content.\r\n"; - break; + return; } + + response << "20 text/gemini\r\nUpload complete.\n"; } } // namespace maple::titan diff --git a/maple/titan.hh b/maple/titan.hh index e8cd8b1..e2109a7 100644 --- a/maple/titan.hh +++ b/maple/titan.hh @@ -21,19 +21,23 @@ #ifndef TITAN_HH #define TITAN_HH -#include +#include +#include #include -#include +#include +#include -namespace maple { -namespace titan { -/// Convert a `std::vector` of Titan parameters into a key/ value `std::map` -auto parameters_to_map(const std::vector &) - -> std::map; +namespace maple::titan { +struct Upload { + std::string path; + std::size_t size_bytes; +}; -auto handle_client(std::stringstream &, std::string, const std::string &, - std::size_t) -> void; -} // namespace titan -} // namespace maple +auto parse_upload(std::string_view header, std::string_view token, + std::size_t maximum_upload_bytes) -> std::optional; +auto handle_client(std::stringstream &response, std::string_view request, + std::string_view token, std::size_t maximum_upload_bytes) + -> void; +} // namespace maple::titan -#endif // TITAN_HH +#endif diff --git a/tests/test_server.py b/tests/test_server.py index 4360dc2..8de1462 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -177,7 +177,6 @@ def test_missing_index(self): self.start() self.assertTrue(self.request(b'gemini://localhost/missing/\r\n').startswith(b'51')) - @unittest.expectedFailure def test_malformed_titan_keeps_server_alive(self): self.start() @@ -189,7 +188,6 @@ def test_malformed_titan_keeps_server_alive(self): self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') self.assertTrue(self.request(b'gemini://localhost/\r\n').startswith(b'20')) - @unittest.expectedFailure def test_strict_upload_size(self): self.start(TITAN_MAX_SIZE='4') @@ -200,7 +198,6 @@ def test_strict_upload_size(self): self.assertTrue(response.startswith(b'59')) self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') - @unittest.expectedFailure def test_negative_configured_size(self): environment = {**os.environ, 'TITAN': '1', 'TITAN_MAX_SIZE': '-1'} result = subprocess.run([str(self.binary)], cwd=self.directory, env=environment, @@ -220,7 +217,6 @@ def test_fragmented_request_and_body(self): self.assertTrue(response.startswith(b'20')) self.assertEqual((self.content / 'new.gmi').read_bytes(), b'DATA') - @unittest.expectedFailure def test_incomplete_upload_preserves_file(self): self.start() @@ -239,7 +235,6 @@ def test_large_upload(self): self.assertTrue(self.upload('/large.gmi', body).startswith(b'20')) self.assertEqual((self.content / 'large.gmi').read_bytes(), body) - @unittest.expectedFailure def test_wrong_token(self): self.start(TITAN_TOKEN='expected') self.assertTrue(self.upload('/existing.gmi', b'BAD', token='wrong').startswith(b'59')) From e0fccb4dd7035cb1aab7428829b14d782922ce32 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:39:13 +0000 Subject: [PATCH 04/19] fix: read complete requests and publish uploads atomically --- build.ninja | 3 +- maple/connection.cc | 77 ++++++++++++++++ maple/connection.hh | 24 +++++ maple/content.cc | 41 +++++++-- maple/maple.cc | 204 ++++++++++++++++++++----------------------- maple/titan.cc | 33 ------- maple/titan.hh | 4 - tests/test_server.py | 125 +++++++++++++++++++++++++- 8 files changed, 352 insertions(+), 159 deletions(-) create mode 100644 maple/connection.cc create mode 100644 maple/connection.hh diff --git a/build.ninja b/build.ninja index f8f8b48..1721dd5 100644 --- a/build.ninja +++ b/build.ninja @@ -13,9 +13,10 @@ rule link build $outdir/$name.o: compile $srcdir/$name.cc build $outdir/gemini.o: compile $srcdir/gemini.cc +build $outdir/connection.o: compile $srcdir/connection.cc build $outdir/content.o: compile $srcdir/content.cc build $outdir/titan.o: compile $srcdir/titan.cc -build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o $outdir/content.o +build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o $outdir/content.o $outdir/connection.o default $outdir/$name diff --git a/maple/connection.cc b/maple/connection.cc new file mode 100644 index 0000000..82ae64d --- /dev/null +++ b/maple/connection.cc @@ -0,0 +1,77 @@ +#include "connection.hh" +#include +#include + +namespace maple { +auto Connection::read_header() -> std::optional { + constexpr std::size_t maximum_url_bytes = 1024; + constexpr std::size_t header_terminator_bytes = 2; + std::string header; + + while (header.size() < maximum_url_bytes + header_terminator_bytes) { + char character = '\0'; + std::size_t byte_count = 0; + + if (SSL_read_ex(tls_session_, &character, 1, &byte_count) != 1 || + byte_count != 1) { + return std::nullopt; + } + + if (character == '\n') { + if (header.empty() || header.back() != '\r') { + return std::nullopt; + } + + header.pop_back(); + + return header; + } + + if (character == '\0' || (!header.empty() && header.back() == '\r')) { + return std::nullopt; + } + + header += character; + } + + return std::nullopt; +} + +auto Connection::read_body(std::size_t size_bytes) + -> std::optional { + constexpr std::size_t buffer_capacity_bytes = 8192; + std::string body; + std::array buffer{}; + + while (body.size() < size_bytes) { + std::size_t byte_count = 0; + const std::size_t remaining_bytes = + std::min(buffer.size(), size_bytes - body.size()); + + if (SSL_read_ex(tls_session_, buffer.data(), remaining_bytes, &byte_count) != 1 || + byte_count == 0) { + return std::nullopt; + } + + body.append(buffer.data(), byte_count); + } + + return body; +} + +auto Connection::write(std::string_view response) -> bool { + while (!response.empty()) { + std::size_t byte_count = 0; + + if (SSL_write_ex(tls_session_, response.data(), response.size(), + &byte_count) != 1 || + byte_count == 0) { + return false; + } + + response.remove_prefix(byte_count); + } + + return true; +} +} // namespace maple diff --git a/maple/connection.hh b/maple/connection.hh new file mode 100644 index 0000000..678e69b --- /dev/null +++ b/maple/connection.hh @@ -0,0 +1,24 @@ +#ifndef MAPLE_CONNECTION_HH +#define MAPLE_CONNECTION_HH + +#include +#include +#include +#include +#include + +namespace maple { +class Connection { +public: + explicit Connection(SSL *tls_session) : tls_session_(tls_session) {} + + auto read_header() -> std::optional; + auto read_body(std::size_t size_bytes) -> std::optional; + auto write(std::string_view response) -> bool; + +private: + SSL *tls_session_; +}; +} // namespace maple + +#endif diff --git a/maple/content.cc b/maple/content.cc index 688c9a6..dface3f 100644 --- a/maple/content.cc +++ b/maple/content.cc @@ -156,16 +156,29 @@ auto write(std::string_view path, std::string_view body) -> bool { return false; } - Descriptor file(openat(destination->directory.get(), - destination->name.c_str(), - O_WRONLY | O_CREAT | O_NOFOLLOW | O_NONBLOCK, 0600)); struct stat status{}; + const int existing = + fstatat(destination->directory.get(), destination->name.c_str(), &status, + AT_SYMLINK_NOFOLLOW); - if (file.get() < 0 || fstat(file.get(), &status) < 0 || - !S_ISREG(status.st_mode) || ftruncate(file.get(), 0) < 0) { + if ((existing == 0 && !S_ISREG(status.st_mode)) || + (existing < 0 && errno != ENOENT)) { + return false; + } + + static unsigned long sequence = 0; + const std::string temporary_name = ".maple-upload-" + + std::to_string(getpid()) + "-" + + std::to_string(sequence++); + Descriptor file(openat(destination->directory.get(), temporary_name.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600)); + + if (file.get() < 0) { return false; } + bool complete = true; + while (!body.empty()) { const ssize_t byte_count = ::write(file.get(), body.data(), body.size()); @@ -174,12 +187,26 @@ auto write(std::string_view path, std::string_view body) -> bool { } if (byte_count <= 0) { - return false; + complete = false; + + break; } body.remove_prefix(static_cast(byte_count)); } - return true; + complete = complete && fsync(file.get()) == 0; + + if (complete) { + complete = + renameat(destination->directory.get(), temporary_name.c_str(), + destination->directory.get(), destination->name.c_str()) == 0; + } + + if (!complete) { + unlinkat(destination->directory.get(), temporary_name.c_str(), 0); + } + + return complete; } } // namespace maple::content diff --git a/maple/maple.cc b/maple/maple.cc index 296a6b6..484443b 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -42,18 +42,88 @@ #include "maple.hh" #include "titan.hh" #include "size.hh" +#include "connection.hh" +#include "content.hh" + +namespace { +auto serve_request(maple::Connection &connection, bool titan_enabled, + const std::string &token, std::size_t maximum_upload_bytes) + -> std::string { + constexpr std::string_view titan_scheme = "titan://"; + constexpr std::string_view gemini_scheme = "gemini://"; + const auto header = connection.read_header(); + + if (!header) { + return "59 Invalid request header.\r\n"; + } + + const bool is_titan = header->starts_with(titan_scheme); + const std::size_t scheme_bytes = + is_titan ? titan_scheme.size() : gemini_scheme.size(); + + if (!is_titan && !header->starts_with(gemini_scheme)) { + return "59 Unsupported scheme.\r\n"; + } + + const std::string_view address(*header); + const std::size_t path_start_bytes = address.find('/', scheme_bytes); + const std::string_view authority = + address.substr(scheme_bytes, path_start_bytes - scheme_bytes); + + if (authority.empty() || + address.find_first_of(" \t\r\n#") != std::string_view::npos) { + return "59 Invalid URL.\r\n"; + } + + const std::string path = path_start_bytes == std::string_view::npos + ? "/" + : std::string(address.substr(path_start_bytes)); + + if (!is_titan) { + std::stringstream response; + const std::size_t query_start_bytes = path.find('?'); + const std::string content_path = path.substr(0, query_start_bytes); + + maple::gemini::handle_client(content_path, response); + + return response.str(); + } + + if (!titan_enabled) { + return "59 Titan is disabled.\r\n"; + } + + const auto upload = + maple::titan::parse_upload(path, token, maximum_upload_bytes); + + if (!upload) { + return "59 Invalid Titan parameters or token.\r\n"; + } + + const auto body = connection.read_body(upload->size_bytes); + + if (!body) { + return "59 Incomplete upload.\r\n"; + } + + if (!maple::content::write(upload->path, *body)) { + return "50 Unable to write content.\r\n"; + } + + return "20 text/gemini\r\nUpload complete.\n"; +} +} // namespace auto main() -> int { - bool titan = false; + bool titan_enabled = false; std::string titan_token; std::size_t maximum_upload_bytes = 0; - // Check if the user is want to support Titan and set it up - if (maple::setup_environment(titan, titan_token, maximum_upload_bytes) == 1) { + if (maple::setup_environment(titan_enabled, titan_token, + maximum_upload_bytes) == 1) { return EXIT_FAILURE; } - // Try a graceful shutdown when a SIGINT is detected signal(SIGINT, [](int _signal) -> void { std::cout << "shutdown(" << _signal << ")\n"; close(maple::maple_socket); @@ -61,129 +131,40 @@ auto main() -> int { }); signal(SIGPIPE, SIG_IGN); - // Setup SSL if (maple::setup_ssl() == EXIT_FAILURE) { return EXIT_FAILURE; } - // Listen and serve connections for (;;) { - sockaddr_in socket_address_{}; - unsigned int socket_address_length = sizeof(socket_address_); - SSL *ssl; + sockaddr_in socket_address{}; + unsigned int socket_address_bytes = sizeof(socket_address); + SSL *tls_session; const int client = accept(maple::maple_socket, - reinterpret_cast(&socket_address_), - &socket_address_length); + reinterpret_cast(&socket_address), + &socket_address_bytes); if (client < 0) { return maple::prepare_exit_with("unable to accept", false); } - ssl = SSL_new(maple::ssl_context); + tls_session = SSL_new(maple::ssl_context); - SSL_set_fd(ssl, client); + SSL_set_fd(tls_session, client); - if (SSL_accept(ssl) <= 0) { + if (SSL_accept(tls_session) <= 0) { ERR_print_errors_fp(stderr); } else { - std::stringstream response; - std::size_t index_of_junk; - int request_scheme; // Gemini = 1, Titan = 2, Error = 0 - std::size_t bytes_read = 0; - constexpr std::size_t GEMINI_MAXIMUM_REQUEST_SIZE = 1024; - std::array request{}; - const int read_result = - SSL_read_ex(ssl, request.begin(), request.size(), &bytes_read); - - if (read_result > 0 && bytes_read > 0) { - std::string path(request.data(), bytes_read); - - if (path.starts_with("gemini://")) { - request_scheme = 1; - } else if (path.starts_with("titan://")) { - request_scheme = 2; - } else { - request_scheme = 0; - } + maple::Connection connection(tls_session); + const std::string response = serve_request( + connection, titan_enabled, titan_token, maximum_upload_bytes); - if (request_scheme != 0) { - bool request_valid = true; - - // Remove "\r\n" if Gemini - if (request_scheme == 1) { - if (path.size() < 2) { - std::cout << "received malformed gemini request\n"; - - request_valid = false; - } else { - path.resize(path.size() - 2); - } - } - - if (request_valid) { - if (request_scheme == 1) { - constexpr std::size_t GEMINI_PROTOCOL_LENGTH = 9; - - path.erase(0, GEMINI_PROTOCOL_LENGTH); // Remove "gemini://" - } else { - constexpr std::size_t TITAN_PROTOCOL_LENGTH = 8; - - path.erase(0, TITAN_PROTOCOL_LENGTH); // Remove "titan://" - } - - // Try to remove the host, if you cannot; it must be a trailing - // slash-less hostname, so we will respond with the index. - const std::size_t found_first = path.find_first_of('/'); - - if (found_first != std::string::npos) { - path = path.substr(found_first, - path.size() - 1); // Remove host - } else { - path = "/index.gmi"; - } - - if (request_scheme == 1) { - // Remove junk, if any - index_of_junk = path.find_first_of('\n'); - - if (index_of_junk != std::string::npos) { - path.erase(path.find_first_of('\n') - 1, path.size() - 1); - } - } - - // Gemini - if (request_scheme == 1) { - maple::gemini::handle_client(path, response); - } else { // Titan - if (!titan) { - response - << "20 text/gemini\r\nThe server (Maple) does not have " - "Titan support enabled!"; - } else { - maple::titan::handle_client(response, path, titan_token, - maximum_upload_bytes); - } - } - - const std::string response_string = response.str(); - const int bytes_written = - SSL_write(ssl, response_string.c_str(), - static_cast(response_string.size())); - - if (bytes_written <= 0) { - ERR_print_errors_fp(stderr); - } - } - } else { - std::cout << "received a request with an unsupported url scheme\n"; - } - } else { + if (!connection.write(response)) { ERR_print_errors_fp(stderr); } } - SSL_shutdown(ssl); - SSL_free(ssl); + SSL_shutdown(tls_session); + SSL_free(tls_session); close(client); } } @@ -199,12 +180,12 @@ auto prepare_exit_with(const char *message, bool ssl) -> int { return EXIT_FAILURE; } -auto setup_environment(bool &titan, std::string &titan_token, +auto setup_environment(bool &titan_enabled, std::string &titan_token, std::size_t &maximum_upload_bytes) -> int { char *titan_environment = std::getenv("TITAN"); if (titan_environment == nullptr) { - titan = false; + titan_enabled = false; } else { std::string valid_titan_environment(titan_environment); @@ -230,7 +211,8 @@ auto setup_environment(bool &titan, std::string &titan_token, std::cout << "no TITAN_MAX_SIZE set, defaulting to 1024\n"; } else { - const auto parsed_maximum_upload_bytes = maple::parse_size(configured_maximum_upload_bytes); + const auto parsed_maximum_upload_bytes = + maple::parse_size(configured_maximum_upload_bytes); if (!parsed_maximum_upload_bytes) { std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n"; @@ -241,7 +223,7 @@ auto setup_environment(bool &titan, std::string &titan_token, maximum_upload_bytes = *parsed_maximum_upload_bytes; } - titan = true; + titan_enabled = true; } } diff --git a/maple/titan.cc b/maple/titan.cc index 3a5065a..99453e3 100644 --- a/maple/titan.cc +++ b/maple/titan.cc @@ -19,7 +19,6 @@ */ #include "titan.hh" -#include "content.hh" #include "size.hh" namespace maple::titan { @@ -86,36 +85,4 @@ auto parse_upload(std::string_view header, std::string_view token, return Upload{path, *size_bytes}; } - -auto handle_client(std::stringstream &response, std::string_view request, - std::string_view token, std::size_t maximum_upload_bytes) - -> void { - constexpr std::size_t header_terminator_bytes = 2; - const std::size_t header_end_bytes = request.find("\r\n"); - - if (header_end_bytes == std::string_view::npos) { - response << "59 Incomplete Titan header.\r\n"; - - return; - } - - const auto upload = parse_upload(request.substr(0, header_end_bytes), token, - maximum_upload_bytes); - const std::string_view body = - request.substr(header_end_bytes + header_terminator_bytes); - - if (!upload || body.size() != upload->size_bytes) { - response << "59 Invalid Titan request.\r\n"; - - return; - } - - if (!content::write(upload->path, body)) { - response << "50 Unable to write content.\r\n"; - - return; - } - - response << "20 text/gemini\r\nUpload complete.\n"; -} } // namespace maple::titan diff --git a/maple/titan.hh b/maple/titan.hh index e2109a7..96aa87c 100644 --- a/maple/titan.hh +++ b/maple/titan.hh @@ -23,7 +23,6 @@ #include #include -#include #include #include @@ -35,9 +34,6 @@ struct Upload { auto parse_upload(std::string_view header, std::string_view token, std::size_t maximum_upload_bytes) -> std::optional; -auto handle_client(std::stringstream &response, std::string_view request, - std::string_view token, std::size_t maximum_upload_bytes) - -> void; } // namespace maple::titan #endif diff --git a/tests/test_server.py b/tests/test_server.py index 8de1462..78a9a39 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -191,13 +191,86 @@ def test_malformed_titan_keeps_server_alive(self): def test_strict_upload_size(self): self.start(TITAN_MAX_SIZE='4') - for declared_size_bytes in ('0', '1junk', '-1', '99999999999999999999999999'): + for declared_size_bytes in ('5', '1junk', '-1', '99999999999999999999999999'): with self.subTest(size=declared_size_bytes): response = self.upload('/existing.gmi', b'OVERSIZED', declared_size_bytes=declared_size_bytes) self.assertTrue(response.startswith(b'59')) self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + def test_upload_uses_declared_size_across_tls_records(self): + self.start(TITAN_MAX_SIZE='4') + + header = b'titan://localhost/existing.gmi;mime=text/gemini;size=4\r\n' + payload = b'DATAEXTRA' + + for records in ((header + payload,), (header + payload[:4], payload[4:]), + (header + payload[:2], payload[2:])): + with self.subTest(records=len(records), first_record_bytes=len(records[0])): + (self.content / 'existing.gmi').write_bytes(b'ORIGINAL') + + incoming = ssl.MemoryBIO() + outgoing = ssl.MemoryBIO() + session = self.context.wrap_bio(incoming, outgoing, server_hostname='localhost') + + with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=CONNECTION_TIMEOUT_SECONDS) as transport: + while True: + try: + session.do_handshake() + + break + except ssl.SSLWantReadError: + transport.sendall(outgoing.read()) + + encrypted_bytes = transport.recv(RECEIVE_BUFFER_BYTES) + + self.assertTrue(encrypted_bytes) + incoming.write(encrypted_bytes) + + # Queue every TLS record before sending to avoid a race with the response. + for record in records: + session.write(record) + + transport.sendall(outgoing.read()) + + response = bytearray() + + while b'\r\n' not in response: + try: + chunk = session.read(RESPONSE_PREFIX_BYTES) + except ssl.SSLWantReadError: + try: + encrypted_bytes = transport.recv(RECEIVE_BUFFER_BYTES) + except ConnectionResetError: + # Unread surplus ciphertext can reset TCP when the server closes. + break + + if not encrypted_bytes: + break + + incoming.write(encrypted_bytes) + + continue + + if not chunk: + break + + response.extend(chunk) + + if response: + self.assertTrue(response.startswith(b'20'), response) + + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'DATA') + self.assertIsNone(self.process.poll()) + + def test_empty_upload_ignores_surplus_bytes(self): + self.start(TITAN_MAX_SIZE='0') + + response = self.upload('/existing.gmi', b'EXTRA', declared_size_bytes=0) + + self.assertTrue(response.startswith(b'20')) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'') + def test_negative_configured_size(self): environment = {**os.environ, 'TITAN': '1', 'TITAN_MAX_SIZE': '-1'} result = subprocess.run([str(self.binary)], cwd=self.directory, env=environment, @@ -206,7 +279,6 @@ def test_negative_configured_size(self): self.assertNotEqual(result.returncode, 0) - @unittest.expectedFailure def test_fragmented_request_and_body(self): self.start() self.assertEqual(self.request(b'gemini://local', b'host/existing.gmi\r', b'\n'), @@ -226,7 +298,6 @@ def test_incomplete_upload_preserves_file(self): self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') - @unittest.expectedFailure def test_large_upload(self): body_size_bytes = 64 * 1024 body = b'X' * body_size_bytes @@ -244,6 +315,54 @@ def test_failed_write(self): self.start() self.assertTrue(self.upload('/missing/file.gmi', b'DATA').startswith(b'50')) + def test_upload_replaces_without_following_hard_links(self): + os.link(self.directory / 'outside/index.gmi', self.content / 'hardlink.gmi') + self.start() + self.assertTrue(self.upload('/hardlink.gmi', b'NEW').startswith(b'20')) + self.assertEqual((self.content / 'hardlink.gmi').read_bytes(), b'NEW') + self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') + self.assertEqual(list(self.content.glob('.maple-upload-*')), []) + + def test_parameter_order_and_empty_upload(self): + self.start(TITAN_TOKEN='expected') + + response = self.request(b'titan://localhost/empty.gmi;token=expected;size=0;mime=text/gemini\r\n') + + self.assertTrue(response.startswith(b'20')) + self.assertEqual((self.content / 'empty.gmi').read_bytes(), b'') + + def test_duplicate_and_missing_parameters(self): + self.start() + + for parameters in (b'size=1;size=1;mime=text/gemini', b'size=1', + b'mime=text/gemini', b'size=1;mime=text/plain', + b'size=1;mime=text/gemini;broken'): + with self.subTest(parameters=parameters): + response = self.request(b'titan://localhost/existing.gmi;' + parameters + b'\r\nx') + + self.assertTrue(response.startswith(b'59')) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + def test_invalid_headers(self): + self.start() + + for header in (b'gemini://localhost/\n', b'gemini://localhost/\x00.gmi\r\n', + b'gemini://localhost/' + b'x' * 1024 + b'\r\n', + b'other://localhost/\r\n'): + with self.subTest(header=header[:40]): + self.assertTrue(self.request(header).startswith(b'59')) + + def test_nonregular_and_non_gemini_files(self): + (self.content / 'directory.gmi').mkdir() + os.mkfifo(self.content / 'pipe.gmi') + (self.content / 'unsupported.txt').write_bytes(b'UNSUPPORTED') + self.start() + + for path in ('/directory.gmi', '/pipe.gmi', '/unsupported.txt'): + with self.subTest(path=path): + self.assertTrue(self.request(f'gemini://localhost{path}\r\n'.encode()).startswith(b'51')) + self.assertTrue(self.upload(path, b'BAD').startswith(b'50')) + @unittest.expectedFailure def test_idle_handshake_expires(self): self.start() From 3f9e11a61fa76d00815c233d7e907889880a9ce1 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:40:52 +0000 Subject: [PATCH 05/19] fix: bound TLS connections with an absolute deadline --- maple/connection.cc | 119 ++++++++++++++++++++++++++++++++++++++++--- maple/connection.hh | 16 +++++- maple/maple.cc | 21 +++++--- tests/test_server.py | 85 ++++++++++++++++++++++++++++++- 4 files changed, 225 insertions(+), 16 deletions(-) diff --git a/maple/connection.cc b/maple/connection.cc index 82ae64d..2cf602a 100644 --- a/maple/connection.cc +++ b/maple/connection.cc @@ -1,8 +1,93 @@ #include "connection.hh" #include #include +#include +#include namespace maple { +auto Connection::record_progress() -> void { + if (transferring_) { + deadline_ = std::chrono::steady_clock::now() + transfer_timeout; + } +} + +auto Connection::retry(int result) -> bool { + constexpr int maximum_poll_interval_milliseconds = 250; + const int error = SSL_get_error(tls_session_, result); + + if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) { + return false; + } + + const auto remaining = deadline_ - std::chrono::steady_clock::now(); + + if (remaining <= std::chrono::steady_clock::duration::zero()) { + return false; + } + + const auto milliseconds = + std::chrono::ceil(remaining); + pollfd descriptor{ + SSL_get_fd(tls_session_), + static_cast(error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT), 0}; + + // Poll's writability threshold can exceed the space needed for a small write. + const auto wait_duration = + error == SSL_ERROR_WANT_WRITE + ? std::min(milliseconds, std::chrono::milliseconds( + maximum_poll_interval_milliseconds)) + : milliseconds; + const int ready = + poll(&descriptor, 1, static_cast(wait_duration.count())); + + return (ready > 0 && (descriptor.revents & descriptor.events) != 0) || + (ready == 0 && error == SSL_ERROR_WANT_WRITE); +} + +auto Connection::handshake() -> bool { + while (std::chrono::steady_clock::now() < deadline_) { + ERR_clear_error(); + + const int result = SSL_accept(tls_session_); + + if (result == 1) { + return true; + } + + if (!retry(result)) { + return false; + } + } + + return false; +} + +auto Connection::receive(char *buffer, std::size_t size_bytes, + std::size_t &byte_count) -> bool { + while (std::chrono::steady_clock::now() < deadline_) { + ERR_clear_error(); + + const int result = + SSL_read_ex(tls_session_, buffer, size_bytes, &byte_count); + + if (result == 1) { + if (byte_count == 0) { + return false; + } + + record_progress(); + + return true; + } + + if (!retry(result)) { + return false; + } + } + + return false; +} + auto Connection::read_header() -> std::optional { constexpr std::size_t maximum_url_bytes = 1024; constexpr std::size_t header_terminator_bytes = 2; @@ -12,8 +97,7 @@ auto Connection::read_header() -> std::optional { char character = '\0'; std::size_t byte_count = 0; - if (SSL_read_ex(tls_session_, &character, 1, &byte_count) != 1 || - byte_count != 1) { + if (!receive(&character, 1, byte_count)) { return std::nullopt; } @@ -24,6 +108,10 @@ auto Connection::read_header() -> std::optional { header.pop_back(); + transferring_ = true; + + record_progress(); + return header; } @@ -48,8 +136,7 @@ auto Connection::read_body(std::size_t size_bytes) const std::size_t remaining_bytes = std::min(buffer.size(), size_bytes - body.size()); - if (SSL_read_ex(tls_session_, buffer.data(), remaining_bytes, &byte_count) != 1 || - byte_count == 0) { + if (!receive(buffer.data(), remaining_bytes, byte_count)) { return std::nullopt; } @@ -63,13 +150,31 @@ auto Connection::write(std::string_view response) -> bool { while (!response.empty()) { std::size_t byte_count = 0; - if (SSL_write_ex(tls_session_, response.data(), response.size(), - &byte_count) != 1 || - byte_count == 0) { + if (std::chrono::steady_clock::now() >= deadline_) { + return false; + } + + ERR_clear_error(); + + const std::size_t write_bytes = + std::min(response.size(), maximum_write_bytes); + const int result = + SSL_write_ex(tls_session_, response.data(), write_bytes, &byte_count); + + if (result != 1) { + if (!retry(result)) { + return false; + } + + continue; + } + + if (byte_count == 0) { return false; } response.remove_prefix(byte_count); + record_progress(); } return true; diff --git a/maple/connection.hh b/maple/connection.hh index 678e69b..16e2ea6 100644 --- a/maple/connection.hh +++ b/maple/connection.hh @@ -1,6 +1,7 @@ #ifndef MAPLE_CONNECTION_HH #define MAPLE_CONNECTION_HH +#include #include #include #include @@ -10,14 +11,27 @@ namespace maple { class Connection { public: - explicit Connection(SSL *tls_session) : tls_session_(tls_session) {} + explicit Connection(SSL *tls_session) + : tls_session_(tls_session), + deadline_(std::chrono::steady_clock::now() + request_timeout) {} + auto handshake() -> bool; auto read_header() -> std::optional; auto read_body(std::size_t size_bytes) -> std::optional; auto write(std::string_view response) -> bool; private: + auto record_progress() -> void; + auto retry(int result) -> bool; + auto receive(char *buffer, std::size_t size_bytes, std::size_t &byte_count) + -> bool; + + static constexpr std::chrono::seconds request_timeout{5}; + static constexpr std::chrono::seconds transfer_timeout{15}; + static constexpr std::size_t maximum_write_bytes = 8192; SSL *tls_session_; + std::chrono::steady_clock::time_point deadline_; + bool transferring_ = false; }; } // namespace maple diff --git a/maple/maple.cc b/maple/maple.cc index 484443b..af2ac76 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -138,7 +139,6 @@ auto main() -> int { for (;;) { sockaddr_in socket_address{}; unsigned int socket_address_bytes = sizeof(socket_address); - SSL *tls_session; const int client = accept(maple::maple_socket, reinterpret_cast(&socket_address), &socket_address_bytes); @@ -147,14 +147,21 @@ auto main() -> int { return maple::prepare_exit_with("unable to accept", false); } - tls_session = SSL_new(maple::ssl_context); + SSL *tls_session = SSL_new(maple::ssl_context); + const int socket_flags = fcntl(client, F_GETFL, 0); - SSL_set_fd(tls_session, client); + if (tls_session == nullptr || socket_flags < 0 || + fcntl(client, F_SETFL, socket_flags | O_NONBLOCK) < 0 || + SSL_set_fd(tls_session, client) != 1) { + SSL_free(tls_session); + close(client); - if (SSL_accept(tls_session) <= 0) { - ERR_print_errors_fp(stderr); - } else { - maple::Connection connection(tls_session); + continue; + } + + maple::Connection connection(tls_session); + + if (connection.handshake()) { const std::string response = serve_request( connection, titan_enabled, titan_token, maximum_upload_bytes); diff --git a/tests/test_server.py b/tests/test_server.py index 78a9a39..4500e84 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -24,6 +24,7 @@ STARTUP_POLL_INTERVAL_SECONDS = 0.01 STARTUP_PROBE_TIMEOUT_SECONDS = 0.1 STARTUP_TIMEOUT_SECONDS = 3 +TRANSFER_EXPIRY_TIMEOUT_SECONDS = 17 class ServerTests(unittest.TestCase): @classmethod @@ -132,6 +133,11 @@ def request(self, *parts): response.extend(chunk) + @staticmethod + def read_response(connection): + with connection.makefile('rb') as stream: + return stream.read() + def upload(self, path, body, declared_size_bytes=None, token=''): declared_size_bytes = len(body) if declared_size_bytes is None else declared_size_bytes header = f'titan://localhost{path};mime=text/gemini;size={declared_size_bytes}' @@ -306,6 +312,36 @@ def test_large_upload(self): self.assertTrue(self.upload('/large.gmi', body).startswith(b'20')) self.assertEqual((self.content / 'large.gmi').read_bytes(), body) + def test_progressing_download_outlives_request_deadline(self): + slow_reading_duration_seconds = 16 + read_chunk_bytes = 8192 + read_interval_seconds = 0.1 + + # Exceed socket buffers so paced reads exercise the server's deadline. + body = b'X' * (32 * 1024 * 1024) + header = b'20 text/gemini\r\n' + + (self.content / 'large.gmi').write_bytes(body) + self.start() + + with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'gemini://localhost/large.gmi\r\n') + + response = bytearray() + slow_reading_deadline_seconds = time.monotonic() + slow_reading_duration_seconds + + while time.monotonic() < slow_reading_deadline_seconds: + chunk = connection.recv(read_chunk_bytes) + + self.assertTrue(chunk) + response.extend(chunk) + time.sleep(read_interval_seconds) + + response.extend(self.read_response(connection)) + + self.assertEqual(len(response), len(header) + len(body)) + self.assertEqual(bytes(response), header + body) + def test_wrong_token(self): self.start(TITAN_TOKEN='expected') self.assertTrue(self.upload('/existing.gmi', b'BAD', token='wrong').startswith(b'59')) @@ -363,7 +399,6 @@ def test_nonregular_and_non_gemini_files(self): self.assertTrue(self.request(f'gemini://localhost{path}\r\n'.encode()).startswith(b'51')) self.assertTrue(self.upload(path, b'BAD').startswith(b'50')) - @unittest.expectedFailure def test_idle_handshake_expires(self): self.start() @@ -372,6 +407,54 @@ def test_idle_handshake_expires(self): connection.sendall(b'gemini://localhost/\r\n') self.assertIn(b'INDEX', connection.recv(RESPONSE_PREFIX_BYTES)) + def test_slow_header_has_absolute_deadline(self): + maximum_header_fragments = 12 + header_poll_interval_seconds = 0.5 + + self.start() + + start_time_seconds = time.monotonic() + + with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: + for _ in range(maximum_header_fragments): + try: + connection.sendall(b'g') + except (OSError, ssl.SSLError): + break + + time.sleep(header_poll_interval_seconds) + + self.assertLess(time.monotonic() - start_time_seconds, REQUEST_EXPIRY_TIMEOUT_SECONDS) + self.assertTrue(self.request(b'gemini://localhost/\r\n').startswith(b'20')) + + def test_stalled_upload_expires_without_truncation(self): + self.start() + + with self.connect(timeout_seconds=TRANSFER_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'titan://localhost/existing.gmi;mime=text/gemini;size=4\r\na') + self.assertEqual(connection.recv(RESPONSE_PREFIX_BYTES), b'') + + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + self.assertTrue(self.request(b'gemini://localhost/\r\n').startswith(b'20')) + + def test_progressing_upload_outlives_request_deadline(self): + header_pause_seconds = 3 + body_pause_seconds = 9 + + self.start() + + with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'titan://localhost/existing.gmi;mime=text/gemini;size=4') + time.sleep(header_pause_seconds) + connection.sendall(b'\r\n') + time.sleep(body_pause_seconds) + connection.sendall(b'D') + time.sleep(body_pause_seconds) + connection.sendall(b'ATA') + self.assertTrue(self.read_response(connection).startswith(b'20')) + + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'DATA') + @unittest.expectedFailure def test_clean_shutdown(self): self.start() From bc931f3ba9d979afa65b1d41fddaf9c2be82f9b2 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:44:46 +0000 Subject: [PATCH 06/19] refactor: own server resources and stream Gemini responses --- maple/connection.cc | 76 ++++++---- maple/connection.hh | 14 +- maple/content.cc | 64 +-------- maple/content.hh | 3 +- maple/descriptor.hh | 43 ++++++ maple/gemini.cc | 39 +++++- maple/gemini.hh | 5 +- maple/maple.cc | 323 ++++++++++++++++++++----------------------- maple/maple.hh | 36 ----- tests/test_server.py | 131 ++++++++++++------ 10 files changed, 378 insertions(+), 356 deletions(-) create mode 100644 maple/descriptor.hh delete mode 100644 maple/maple.hh diff --git a/maple/connection.cc b/maple/connection.cc index 2cf602a..4d12ac9 100644 --- a/maple/connection.cc +++ b/maple/connection.cc @@ -1,6 +1,7 @@ #include "connection.hh" #include #include +#include #include #include @@ -13,42 +14,61 @@ auto Connection::record_progress() -> void { auto Connection::retry(int result) -> bool { constexpr int maximum_poll_interval_milliseconds = 250; - const int error = SSL_get_error(tls_session_, result); + const int error = SSL_get_error(tls_session_.get(), result); if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) { return false; } - const auto remaining = deadline_ - std::chrono::steady_clock::now(); + while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { + const auto remaining = deadline_ - std::chrono::steady_clock::now(); + const auto milliseconds = + std::chrono::ceil(remaining); + const int timeout_milliseconds = static_cast( + std::min(milliseconds, + std::chrono::milliseconds(maximum_poll_interval_milliseconds)) + .count()); + pollfd descriptor{ + socket_.get(), + static_cast(error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT), 0}; + const int ready = poll(&descriptor, 1, timeout_milliseconds); + + if (ready < 0) { + return false; + } - if (remaining <= std::chrono::steady_clock::duration::zero()) { - return false; + // Poll's writability threshold can exceed the space needed for a small write. + if (ready == 0 && error == SSL_ERROR_WANT_WRITE) { + return !stopping_; + } + + if (ready > 0) { + return !stopping_ && (descriptor.revents & descriptor.events) != 0; + } } - const auto milliseconds = - std::chrono::ceil(remaining); - pollfd descriptor{ - SSL_get_fd(tls_session_), - static_cast(error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT), 0}; - - // Poll's writability threshold can exceed the space needed for a small write. - const auto wait_duration = - error == SSL_ERROR_WANT_WRITE - ? std::min(milliseconds, std::chrono::milliseconds( - maximum_poll_interval_milliseconds)) - : milliseconds; - const int ready = - poll(&descriptor, 1, static_cast(wait_duration.count())); - - return (ready > 0 && (descriptor.revents & descriptor.events) != 0) || - (ready == 0 && error == SSL_ERROR_WANT_WRITE); + return false; +} + +Connection::~Connection() { + if (tls_session_ && SSL_is_init_finished(tls_session_.get())) { + SSL_shutdown(tls_session_.get()); + } } auto Connection::handshake() -> bool { - while (std::chrono::steady_clock::now() < deadline_) { + const int flags = fcntl(socket_.get(), F_GETFL, 0); + + if (!tls_session_ || flags < 0 || + fcntl(socket_.get(), F_SETFL, flags | O_NONBLOCK) < 0 || + SSL_set_fd(tls_session_.get(), socket_.get()) != 1) { + return false; + } + + while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { ERR_clear_error(); - const int result = SSL_accept(tls_session_); + const int result = SSL_accept(tls_session_.get()); if (result == 1) { return true; @@ -64,11 +84,11 @@ auto Connection::handshake() -> bool { auto Connection::receive(char *buffer, std::size_t size_bytes, std::size_t &byte_count) -> bool { - while (std::chrono::steady_clock::now() < deadline_) { + while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { ERR_clear_error(); const int result = - SSL_read_ex(tls_session_, buffer, size_bytes, &byte_count); + SSL_read_ex(tls_session_.get(), buffer, size_bytes, &byte_count); if (result == 1) { if (byte_count == 0) { @@ -150,7 +170,7 @@ auto Connection::write(std::string_view response) -> bool { while (!response.empty()) { std::size_t byte_count = 0; - if (std::chrono::steady_clock::now() >= deadline_) { + if (stopping_ || std::chrono::steady_clock::now() >= deadline_) { return false; } @@ -158,8 +178,8 @@ auto Connection::write(std::string_view response) -> bool { const std::size_t write_bytes = std::min(response.size(), maximum_write_bytes); - const int result = - SSL_write_ex(tls_session_, response.data(), write_bytes, &byte_count); + const int result = SSL_write_ex(tls_session_.get(), response.data(), + write_bytes, &byte_count); if (result != 1) { if (!retry(result)) { diff --git a/maple/connection.hh b/maple/connection.hh index 16e2ea6..7005b17 100644 --- a/maple/connection.hh +++ b/maple/connection.hh @@ -1,7 +1,10 @@ #ifndef MAPLE_CONNECTION_HH #define MAPLE_CONNECTION_HH +#include "descriptor.hh" #include +#include +#include #include #include #include @@ -11,10 +14,13 @@ namespace maple { class Connection { public: - explicit Connection(SSL *tls_session) - : tls_session_(tls_session), + Connection(SSL_CTX *tls_context, int socket, + const volatile std::sig_atomic_t &stopping) + : socket_(socket), tls_session_(SSL_new(tls_context), SSL_free), + stopping_(stopping), deadline_(std::chrono::steady_clock::now() + request_timeout) {} + ~Connection(); auto handshake() -> bool; auto read_header() -> std::optional; auto read_body(std::size_t size_bytes) -> std::optional; @@ -29,7 +35,9 @@ private: static constexpr std::chrono::seconds request_timeout{5}; static constexpr std::chrono::seconds transfer_timeout{15}; static constexpr std::size_t maximum_write_bytes = 8192; - SSL *tls_session_; + Descriptor socket_; + std::unique_ptr tls_session_; + const volatile std::sig_atomic_t &stopping_; std::chrono::steady_clock::time_point deadline_; bool transferring_ = false; }; diff --git a/maple/content.cc b/maple/content.cc index dface3f..c9a5fbb 100644 --- a/maple/content.cc +++ b/maple/content.cc @@ -1,47 +1,13 @@ #include "content.hh" #include -#include #include #include #include #include #include -#include namespace { -class Descriptor { -public: - explicit Descriptor(int value) : value_(value) {} - - Descriptor(const Descriptor &) = delete; - auto operator=(const Descriptor &) -> Descriptor & = delete; - - Descriptor(Descriptor &&other) noexcept - : value_(std::exchange(other.value_, -1)) {} - - auto operator=(Descriptor &&other) noexcept -> Descriptor & { - if (this != &other) { - if (value_ >= 0) { - close(value_); - } - - value_ = std::exchange(other.value_, -1); - } - - return *this; - } - - ~Descriptor() { - if (value_ >= 0) { - close(value_); - } - } - - auto get() const -> int { return value_; } - -private: - int value_; -}; +using maple::Descriptor; struct Destination { Descriptor directory; @@ -109,11 +75,11 @@ auto resolve(std::string_view path) -> std::optional { } // namespace namespace maple::content { -auto read(std::string_view path) -> std::optional { +auto open_read(std::string_view path) -> Descriptor { auto destination = resolve(path); if (!destination) { - return std::nullopt; + return Descriptor(-1); } Descriptor file(openat(destination->directory.get(), @@ -123,30 +89,10 @@ auto read(std::string_view path) -> std::optional { if (file.get() < 0 || fstat(file.get(), &status) < 0 || !S_ISREG(status.st_mode)) { - return std::nullopt; + return Descriptor(-1); } - constexpr std::size_t buffer_capacity_bytes = 8192; - std::string body; - std::array buffer{}; - - for (;;) { - const ssize_t byte_count = ::read(file.get(), buffer.data(), buffer.size()); - - if (byte_count < 0) { - if (errno == EINTR) { - continue; - } - - return std::nullopt; - } - - if (byte_count == 0) { - return body; - } - - body.append(buffer.data(), static_cast(byte_count)); - } + return file; } auto write(std::string_view path, std::string_view body) -> bool { diff --git a/maple/content.hh b/maple/content.hh index e62ca3e..b6b3f94 100644 --- a/maple/content.hh +++ b/maple/content.hh @@ -1,12 +1,13 @@ #ifndef MAPLE_CONTENT_HH #define MAPLE_CONTENT_HH +#include "descriptor.hh" #include #include #include namespace maple::content { -auto read(std::string_view path) -> std::optional; +auto open_read(std::string_view path) -> Descriptor; auto write(std::string_view path, std::string_view body) -> bool; } // namespace maple::content diff --git a/maple/descriptor.hh b/maple/descriptor.hh new file mode 100644 index 0000000..a37f5a7 --- /dev/null +++ b/maple/descriptor.hh @@ -0,0 +1,43 @@ +#ifndef MAPLE_DESCRIPTOR_HH +#define MAPLE_DESCRIPTOR_HH + +#include +#include + +namespace maple { +class Descriptor { +public: + explicit Descriptor(int value) : value_(value) {} + + Descriptor(const Descriptor &) = delete; + auto operator=(const Descriptor &) -> Descriptor & = delete; + + Descriptor(Descriptor &&other) noexcept + : value_(std::exchange(other.value_, -1)) {} + + auto operator=(Descriptor &&other) noexcept -> Descriptor & { + if (this != &other) { + if (value_ >= 0) { + close(value_); + } + + value_ = std::exchange(other.value_, -1); + } + + return *this; + } + + ~Descriptor() { + if (value_ >= 0) { + close(value_); + } + } + + auto get() const -> int { return value_; } + +private: + int value_; +}; +} // namespace maple + +#endif diff --git a/maple/gemini.cc b/maple/gemini.cc index a4d5822..e0815c3 100644 --- a/maple/gemini.cc +++ b/maple/gemini.cc @@ -20,18 +20,43 @@ #include "gemini.hh" #include "content.hh" +#include +#include namespace maple::gemini { -auto handle_client(const std::string &path, std::stringstream &response) - -> void { - const auto body = content::read(path); +auto handle_client(Connection &connection, const std::string &path) -> bool { + const auto file = content::open_read(path); - if (!body) { - response << "51 File not found.\r\n"; + if (file.get() < 0) { + return connection.write("51 File not found.\r\n"); + } - return; + if (!connection.write("20 text/gemini\r\n")) { + return false; } - response << "20 text/gemini\r\n" << *body; + constexpr std::size_t buffer_capacity_bytes = 8192; + std::array buffer{}; + + for (;;) { + const ssize_t byte_count = ::read(file.get(), buffer.data(), buffer.size()); + + if (byte_count < 0 && errno == EINTR) { + continue; + } + + if (byte_count < 0) { + return false; + } + + if (byte_count == 0) { + return true; + } + + if (!connection.write(std::string_view( + buffer.data(), static_cast(byte_count)))) { + return false; + } + } } } // namespace maple::gemini diff --git a/maple/gemini.hh b/maple/gemini.hh index a4a4a90..b4bf591 100644 --- a/maple/gemini.hh +++ b/maple/gemini.hh @@ -21,13 +21,12 @@ #ifndef GEMINI_HH #define GEMINI_HH -#include +#include "connection.hh" #include namespace maple { namespace gemini { -auto handle_client(const std::string &path, std::stringstream &response) - -> void; +auto handle_client(Connection &connection, const std::string &path) -> bool; } } // namespace maple diff --git a/maple/maple.cc b/maple/maple.cc index af2ac76..791d75e 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -18,44 +18,85 @@ * SPDX-License-Identifier: GPL-3.0-only */ +#include "connection.hh" +#include "content.hh" +#include "gemini.hh" +#include "size.hh" +#include "titan.hh" #include -#include +#include #include #include -#include #include #include -#include #include +#include +#include #include #include -#include -#include -#include +#include #include -#include -#if !defined(__APPLE__) -#include -#endif +namespace { +volatile std::sig_atomic_t stopping = 0; -#include "gemini.hh" -#include "maple.hh" -#include "titan.hh" -#include "size.hh" -#include "connection.hh" -#include "content.hh" +struct Configuration { + bool titan_enabled = false; + std::string token; + std::size_t maximum_upload_bytes = 1024; +}; + +auto configuration() -> std::optional { + Configuration settings; + const char *enabled = std::getenv("TITAN"); + + if (enabled == nullptr) { + return settings; + } + + std::string value(enabled); + + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + + settings.titan_enabled = value == "1" || value == "true"; + + if (!settings.titan_enabled) { + return settings; + } + + if (const char *token = std::getenv("TITAN_TOKEN")) { + settings.token = token; + } + + if (const char *configured_maximum_upload_bytes = + std::getenv("TITAN_MAX_SIZE")) { + const auto parsed_maximum_upload_bytes = + maple::parse_size(configured_maximum_upload_bytes); + + if (!parsed_maximum_upload_bytes) { + std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n"; + + return std::nullopt; + } + + settings.maximum_upload_bytes = *parsed_maximum_upload_bytes; + } + + return settings; +} -namespace { auto serve_request(maple::Connection &connection, bool titan_enabled, const std::string &token, std::size_t maximum_upload_bytes) - -> std::string { + -> bool { constexpr std::string_view titan_scheme = "titan://"; constexpr std::string_view gemini_scheme = "gemini://"; const auto header = connection.read_header(); if (!header) { - return "59 Invalid request header.\r\n"; + return connection.write("59 Invalid request header.\r\n"); } const bool is_titan = header->starts_with(titan_scheme); @@ -63,7 +104,7 @@ auto serve_request(maple::Connection &connection, bool titan_enabled, is_titan ? titan_scheme.size() : gemini_scheme.size(); if (!is_titan && !header->starts_with(gemini_scheme)) { - return "59 Unsupported scheme.\r\n"; + return connection.write("59 Unsupported scheme.\r\n"); } const std::string_view address(*header); @@ -73,7 +114,7 @@ auto serve_request(maple::Connection &connection, bool titan_enabled, if (authority.empty() || address.find_first_of(" \t\r\n#") != std::string_view::npos) { - return "59 Invalid URL.\r\n"; + return connection.write("59 Invalid URL.\r\n"); } const std::string path = path_start_bytes == std::string_view::npos @@ -81,215 +122,147 @@ auto serve_request(maple::Connection &connection, bool titan_enabled, : std::string(address.substr(path_start_bytes)); if (!is_titan) { - std::stringstream response; const std::size_t query_start_bytes = path.find('?'); const std::string content_path = path.substr(0, query_start_bytes); - maple::gemini::handle_client(content_path, response); - - return response.str(); + return maple::gemini::handle_client(connection, content_path); } if (!titan_enabled) { - return "59 Titan is disabled.\r\n"; + return connection.write("59 Titan is disabled.\r\n"); } const auto upload = maple::titan::parse_upload(path, token, maximum_upload_bytes); if (!upload) { - return "59 Invalid Titan parameters or token.\r\n"; + return connection.write("59 Invalid Titan parameters or token.\r\n"); } const auto body = connection.read_body(upload->size_bytes); if (!body) { - return "59 Incomplete upload.\r\n"; + return connection.write("59 Incomplete upload.\r\n"); } if (!maple::content::write(upload->path, *body)) { - return "50 Unable to write content.\r\n"; + return connection.write("50 Unable to write content.\r\n"); } - return "20 text/gemini\r\nUpload complete.\n"; + return connection.write("20 text/gemini\r\nUpload complete.\n"); +} + +auto listen_socket() -> maple::Descriptor { + maple::Descriptor listener(socket(AF_INET, SOCK_STREAM, 0)); + sockaddr_in address{}; + const int reuse_address = 1; + + address.sin_family = AF_INET; + address.sin_port = htons(1965); + address.sin_addr.s_addr = htonl(INADDR_ANY); + + if (listener.get() < 0 || + setsockopt(listener.get(), SOL_SOCKET, SO_REUSEADDR, &reuse_address, + sizeof(reuse_address)) < 0 || + fcntl(listener.get(), F_SETFL, O_NONBLOCK) < 0 || + bind(listener.get(), reinterpret_cast(&address), + sizeof(address)) < 0 || + listen(listener.get(), SOMAXCONN) < 0) { + perror("Unable to listen on port 1965"); + + return maple::Descriptor(-1); + } + + return listener; } } // namespace auto main() -> int { - bool titan_enabled = false; - std::string titan_token; - std::size_t maximum_upload_bytes = 0; + const auto settings = configuration(); - if (maple::setup_environment(titan_enabled, titan_token, - maximum_upload_bytes) == 1) { + if (!settings) { return EXIT_FAILURE; } - signal(SIGINT, [](int _signal) -> void { - std::cout << "shutdown(" << _signal << ")\n"; - close(maple::maple_socket); - SSL_CTX_free(maple::ssl_context); - }); - signal(SIGPIPE, SIG_IGN); + struct sigaction action{}; - if (maple::setup_ssl() == EXIT_FAILURE) { - return EXIT_FAILURE; - } + action.sa_handler = [](int) { stopping = 1; }; - for (;;) { - sockaddr_in socket_address{}; - unsigned int socket_address_bytes = sizeof(socket_address); - const int client = accept(maple::maple_socket, - reinterpret_cast(&socket_address), - &socket_address_bytes); + sigemptyset(&action.sa_mask); - if (client < 0) { - return maple::prepare_exit_with("unable to accept", false); - } + if (sigaction(SIGINT, &action, nullptr) < 0 || + sigaction(SIGTERM, &action, nullptr) < 0) { + perror("Unable to install shutdown handlers"); - SSL *tls_session = SSL_new(maple::ssl_context); - const int socket_flags = fcntl(client, F_GETFL, 0); + return EXIT_FAILURE; + } - if (tls_session == nullptr || socket_flags < 0 || - fcntl(client, F_SETFL, socket_flags | O_NONBLOCK) < 0 || - SSL_set_fd(tls_session, client) != 1) { - SSL_free(tls_session); - close(client); + action.sa_handler = SIG_IGN; - continue; - } - - maple::Connection connection(tls_session); + if (sigaction(SIGPIPE, &action, nullptr) < 0) { + perror("Unable to ignore SIGPIPE"); - if (connection.handshake()) { - const std::string response = serve_request( - connection, titan_enabled, titan_token, maximum_upload_bytes); + return EXIT_FAILURE; + } - if (!connection.write(response)) { - ERR_print_errors_fp(stderr); - } - } + const std::unique_ptr tls_context( + SSL_CTX_new(TLS_server_method()), SSL_CTX_free); + + if (!tls_context || + SSL_CTX_use_certificate_file(tls_context.get(), ".maple/public.pem", + SSL_FILETYPE_PEM) != 1 || + SSL_CTX_use_PrivateKey_file(tls_context.get(), ".maple/private.pem", + SSL_FILETYPE_PEM) != 1 || + SSL_CTX_check_private_key(tls_context.get()) != 1) { + std::cerr + << "Unable to load the server certificate and private key from .maple.\n"; + ERR_print_errors_fp(stderr); - SSL_shutdown(tls_session); - SSL_free(tls_session); - close(client); + return EXIT_FAILURE; } -} -namespace maple { -auto prepare_exit_with(const char *message, bool ssl) -> int { - perror(message); + const auto listener = listen_socket(); - if (ssl) { - ERR_print_errors_fp(stderr); + if (listener.get() < 0) { + return EXIT_FAILURE; } - return EXIT_FAILURE; -} + while (!stopping) { + constexpr int connection_poll_interval_milliseconds = 250; + pollfd descriptor{listener.get(), POLLIN, 0}; + const int ready = + poll(&descriptor, 1, connection_poll_interval_milliseconds); -auto setup_environment(bool &titan_enabled, std::string &titan_token, - std::size_t &maximum_upload_bytes) -> int { - char *titan_environment = std::getenv("TITAN"); + if (ready < 0 && errno != EINTR) { + perror("Unable to wait for a connection"); - if (titan_environment == nullptr) { - titan_enabled = false; - } else { - std::string valid_titan_environment(titan_environment); + return EXIT_FAILURE; + } - std::transform( - valid_titan_environment.begin(), valid_titan_environment.end(), - valid_titan_environment.begin(), - [](unsigned char character) -> int { return std::tolower(character); }); + if (ready <= 0 || stopping) { + continue; + } - if (valid_titan_environment == "true" || valid_titan_environment == "1") { - char *unvalidated_titan_token = std::getenv("TITAN_TOKEN"); - char *configured_maximum_upload_bytes = std::getenv("TITAN_MAX_SIZE"); + const int client = accept(listener.get(), nullptr, nullptr); - if (unvalidated_titan_token == nullptr) { - titan_token = ""; - } else { - titan_token = std::string(unvalidated_titan_token); + if (client < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR || + errno == ECONNABORTED) { + continue; } - if (configured_maximum_upload_bytes == nullptr) { - constexpr std::size_t default_maximum_upload_bytes = 1024; - - maximum_upload_bytes = default_maximum_upload_bytes; - - std::cout << "no TITAN_MAX_SIZE set, defaulting to 1024\n"; - } else { - const auto parsed_maximum_upload_bytes = - maple::parse_size(configured_maximum_upload_bytes); - - if (!parsed_maximum_upload_bytes) { - std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n"; - - return EXIT_FAILURE; - } - - maximum_upload_bytes = *parsed_maximum_upload_bytes; - } + perror("Unable to accept a connection"); - titan_enabled = true; + return EXIT_FAILURE; } - } - - return 0; -} - -auto setup_ssl() -> int { - sockaddr_in socket_address{}; - - // Setup OpenSSL - SSL_library_init(); - SSL_load_error_strings(); - - maple::ssl_context = SSL_CTX_new(TLS_server_method()); - - if (maple::ssl_context == nullptr) { - return maple::prepare_exit_with("unable to create ssl context", true); - } - - if (SSL_CTX_use_certificate_file(maple::ssl_context, ".maple/public.pem", - SSL_FILETYPE_PEM) <= 0) { - return maple::prepare_exit_with("unable to use certificate file", true); - } - - if (SSL_CTX_use_PrivateKey_file(maple::ssl_context, ".maple/private.pem", - SSL_FILETYPE_PEM) <= 0) { - return maple::prepare_exit_with("unable to use private key file", true); - } - - constexpr std::size_t GEMINI_PORT = 1965; - - socket_address.sin_family = AF_INET; - socket_address.sin_port = htons(GEMINI_PORT); - socket_address.sin_addr.s_addr = htonl(INADDR_ANY); - maple::maple_socket = socket(AF_INET, SOCK_STREAM, 0); - if (maple::maple_socket < 0) { - return maple::prepare_exit_with("unable to create socket", false); - } - - // Reuse address. Allows the use of the address instantly after a SIGINT - // without having to wait for the socket to die. - int reuse_addr = 1; + maple::Connection connection(tls_context.get(), client, stopping); - if (setsockopt(maple::maple_socket, SOL_SOCKET, SO_REUSEADDR, &reuse_addr, - sizeof(int)) < 0) { - return maple::prepare_exit_with("unable to set socket options (SO_LINGER)", - false); - } - - if (bind(maple::maple_socket, reinterpret_cast(&socket_address), - sizeof(socket_address)) < 0) { - return maple::prepare_exit_with("unable to bind", false); - } - - if (listen(maple::maple_socket, 1) < 0) { - return maple::prepare_exit_with("unable to listen", false); + if (connection.handshake()) { + serve_request(connection, settings->titan_enabled, settings->token, + settings->maximum_upload_bytes); + } } - return 0; + return EXIT_SUCCESS; } -} // namespace maple diff --git a/maple/maple.hh b/maple/maple.hh deleted file mode 100644 index 0ae6a2d..0000000 --- a/maple/maple.hh +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This file is part of Maple . - * Copyright (C) 2022-2022 Fuwn - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * Copyright (C) 2022-2022 Fuwn - * SPDX-License-Identifier: GPL-3.0-only - */ - -#ifndef MAPLE_HH -#define MAPLE_HH - -#include -#include - -namespace maple { -static int maple_socket; -static SSL_CTX *ssl_context; - -auto prepare_exit_with(const char *, bool) -> int; -auto setup_environment(bool &, std::string &, std::size_t &) -> int; -auto setup_ssl() -> int; -} // namespace maple - -#endif // MAPLE_HH diff --git a/tests/test_server.py b/tests/test_server.py index 4500e84..8689917 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,7 @@ import os import pathlib +import select import shutil import signal import socket @@ -16,11 +17,13 @@ DEFAULT_PORT = 1965 IDLE_CONNECTION_TIMEOUT_SECONDS = 1 PROCESS_EXIT_TIMEOUT_SECONDS = 2 +QUEUED_REQUEST_TIMEOUT_SECONDS = 35 RECEIVE_BUFFER_BYTES = 65536 REQUEST_EXPIRY_TIMEOUT_SECONDS = 7 REQUEST_FRAGMENT_DELAY_SECONDS = 0.02 RESPONSE_PREFIX_BYTES = 4096 SERVER_SETTLE_SECONDS = 0.1 +SHUTDOWN_SETTLE_SECONDS = 0.05 STARTUP_POLL_INTERVAL_SECONDS = 0.01 STARTUP_PROBE_TIMEOUT_SECONDS = 0.1 STARTUP_TIMEOUT_SECONDS = 3 @@ -123,15 +126,7 @@ def request(self, *parts): connection.sendall(part) time.sleep(REQUEST_FRAGMENT_DELAY_SECONDS) - response = bytearray() - - while True: - chunk = connection.recv(RECEIVE_BUFFER_BYTES) - - if not chunk: - return bytes(response) - - response.extend(chunk) + return self.read_response(connection) @staticmethod def read_response(connection): @@ -312,36 +307,6 @@ def test_large_upload(self): self.assertTrue(self.upload('/large.gmi', body).startswith(b'20')) self.assertEqual((self.content / 'large.gmi').read_bytes(), body) - def test_progressing_download_outlives_request_deadline(self): - slow_reading_duration_seconds = 16 - read_chunk_bytes = 8192 - read_interval_seconds = 0.1 - - # Exceed socket buffers so paced reads exercise the server's deadline. - body = b'X' * (32 * 1024 * 1024) - header = b'20 text/gemini\r\n' - - (self.content / 'large.gmi').write_bytes(body) - self.start() - - with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: - connection.sendall(b'gemini://localhost/large.gmi\r\n') - - response = bytearray() - slow_reading_deadline_seconds = time.monotonic() + slow_reading_duration_seconds - - while time.monotonic() < slow_reading_deadline_seconds: - chunk = connection.recv(read_chunk_bytes) - - self.assertTrue(chunk) - response.extend(chunk) - time.sleep(read_interval_seconds) - - response.extend(self.read_response(connection)) - - self.assertEqual(len(response), len(header) + len(body)) - self.assertEqual(bytes(response), header + body) - def test_wrong_token(self): self.start(TITAN_TOKEN='expected') self.assertTrue(self.upload('/existing.gmi', b'BAD', token='wrong').startswith(b'59')) @@ -405,25 +370,32 @@ def test_idle_handshake_expires(self): with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: connection.sendall(b'gemini://localhost/\r\n') - self.assertIn(b'INDEX', connection.recv(RESPONSE_PREFIX_BYTES)) + self.assertIn(b'INDEX', self.read_response(connection)) def test_slow_header_has_absolute_deadline(self): - maximum_header_fragments = 12 + maximum_header_fragments = 14 header_poll_interval_seconds = 0.5 self.start() start_time_seconds = time.monotonic() + closed = False with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: for _ in range(maximum_header_fragments): try: connection.sendall(b'g') + + if select.select([connection], [], [], header_poll_interval_seconds)[0]: + closed = connection.recv(RESPONSE_PREFIX_BYTES) == b'' + + break except (OSError, ssl.SSLError): - break + closed = True - time.sleep(header_poll_interval_seconds) + break + self.assertTrue(closed) self.assertLess(time.monotonic() - start_time_seconds, REQUEST_EXPIRY_TIMEOUT_SECONDS) self.assertTrue(self.request(b'gemini://localhost/\r\n').startswith(b'20')) @@ -455,7 +427,78 @@ def test_progressing_upload_outlives_request_deadline(self): self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'DATA') - @unittest.expectedFailure + def test_shutdown_during_handshake(self): + self.start() + + with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): + time.sleep(SHUTDOWN_SETTLE_SECONDS) + self.process.send_signal(signal.SIGTERM) + self.assertEqual(self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS), 0) + + def test_shutdown_during_upload(self): + self.start() + + with self.connect() as connection: + connection.sendall(b'titan://localhost/existing.gmi;mime=text/gemini;size=4\r\na') + time.sleep(SHUTDOWN_SETTLE_SECONDS) + self.process.send_signal(signal.SIGINT) + self.assertEqual(self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS), 0) + + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + def test_large_download(self): + body = bytes(range(256)) * 8192 + + (self.content / 'large.gmi').write_bytes(body) + self.start() + self.assertEqual(self.request(b'gemini://localhost/large.gmi\r\n'), + b'20 text/gemini\r\n' + body) + + def test_stalled_download_expires(self): + with (self.content / 'large.gmi').open('wb') as output: + output.truncate(32 * 1024 * 1024) + + self.start() + + with self.connect(timeout_seconds=TRANSFER_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'gemini://localhost/large.gmi\r\n') + time.sleep(SERVER_SETTLE_SECONDS) + + # Allow initial ciphertext buffering before the inactivity window begins. + with self.connect(timeout_seconds=QUEUED_REQUEST_TIMEOUT_SECONDS) as second: + second.sendall(b'gemini://localhost/\r\n') + self.assertIn(b'INDEX', self.read_response(second)) + + def test_progressing_download_outlives_request_deadline(self): + slow_reading_duration_seconds = 16 + read_chunk_bytes = 8192 + read_interval_seconds = 0.1 + + # Exceed socket buffers so paced reads exercise the server's deadline. + body = b'X' * (32 * 1024 * 1024) + header = b'20 text/gemini\r\n' + + (self.content / 'large.gmi').write_bytes(body) + self.start() + + with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: + connection.sendall(b'gemini://localhost/large.gmi\r\n') + + response = bytearray() + slow_reading_deadline_seconds = time.monotonic() + slow_reading_duration_seconds + + while time.monotonic() < slow_reading_deadline_seconds: + chunk = connection.recv(read_chunk_bytes) + + self.assertTrue(chunk) + response.extend(chunk) + time.sleep(read_interval_seconds) + + response.extend(self.read_response(connection)) + + self.assertEqual(len(response), len(header) + len(body)) + self.assertEqual(bytes(response), header + body) + def test_clean_shutdown(self): self.start() self.process.send_signal(signal.SIGINT) From b298f1585a85cb072bf0fdbf28e7951f53edd11b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:46:18 +0000 Subject: [PATCH 07/19] build: consolidate on Ninja and run regression checks in CI --- .github/workflows/check.yaml | 19 +++++++++++++++++++ .gitignore | 7 ++++--- Tupfile | 22 ---------------------- build.ninja | 33 ++++++++++++++++++--------------- tests/test_build.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/check.yaml delete mode 100644 Tupfile create mode 100644 tests/test_build.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..0fd62b3 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,19 @@ +name: Check + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Install Build Dependencies + run: sudo apt-get update && sudo apt-get install -y clang ninja-build pkg-config libssl-dev + - name: Build and Test + run: ninja check diff --git a/.gitignore b/.gitignore index c3f4a1c..e347170 100644 --- a/.gitignore +++ b/.gitignore @@ -5,14 +5,15 @@ .idea .vscode -# Tup -.tup - # Build Artifacts build # Ninja .ninja_log +.ninja_deps # Nix result + +# Python Tests +__pycache__/ diff --git a/Tupfile b/Tupfile deleted file mode 100644 index 26a606d..0000000 --- a/Tupfile +++ /dev/null @@ -1,22 +0,0 @@ -NAME = maple - -# Input & Output Directories -SOURCE_DIRECTORY = $(NAME) -INCLUDE_DIRECTORY = $(NAME) -BUILD_DIRECTORY = build - -# Compiler Configuration -CXX = clang++ -CXX_EXTENSION = cc -CXX_FLAGS = -std=c++23 -I $(INCLUDE_DIRECTORY) -Weverything -Wno-padded -Wno-c++98-compat -MMD -Wno-c++98-compat-pedantic -LD_FLAGS = -lssl -lcrypto - -# Clang-Tidy Configuration -CLANG_TIDY_CHECKS = '-*,bugprone-*,clang-analyzer-*,concurrency-*,cppcoreguildelines-*,llvm-*,misc-*,modernize-*,performance-*,portability-*,readability-*,-readability-function-cognitive-complexity,-bugprone-easily-swappable-parameters,-concurrency-mt-unsafe' -CLANG_TIDY_FLAGS = -checks=$(CLANG_TIDY_CHECKS) -warnings-as-errors=* -quiet - -# : foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) $(INCLUDE_DIRECTORY)/*.hh |> clang-format -i -style=LLVM %f |> -: foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) |> clang-tidy $(CLANG_TIDY_FLAGS) %f -- $(CXX_FLAGS) |> -: foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) |> ^j^ $(CXX) $(CXX_FLAGS) -MF $(BUILD_DIRECTORY)/%B.d -c %f -o %o |> $(BUILD_DIRECTORY)/%B.o | $(BUILD_DIRECTORY)/%B.d -: $(BUILD_DIRECTORY)/*.o |> $(CXX) $(LD_FLAGS) %f -o %o |> $(BUILD_DIRECTORY)/$(NAME) - diff --git a/build.ninja b/build.ninja index 1721dd5..8e3a7b5 100644 --- a/build.ninja +++ b/build.ninja @@ -1,22 +1,25 @@ -cc = clang++ -cxxflags = -std=c++20 -Weverything -Wno-padded -Wno-c++98-compat -MMD -Wno-c++98-compat-pedantic -ldflags = -lssl -lcrypto -outdir = build -name = maple -srcdir = $name +compiler = clang++ +compiler_flags = -std=c++20 -O2 -Weverything -Wno-padded -Wno-c++98-compat -Wno-c++98-compat-pedantic +build_directory = build rule compile - command = $cc $cxxflags -c $in -o $out + command = $compiler $compiler_flags $$(pkg-config --cflags openssl) -MMD -MF $out.d -c $in -o $out + depfile = $out.d + deps = gcc rule link - command = $cc $in -o $out $ldflags + command = $compiler $in -o $out $$(pkg-config --libs openssl) -build $outdir/$name.o: compile $srcdir/$name.cc -build $outdir/gemini.o: compile $srcdir/gemini.cc -build $outdir/connection.o: compile $srcdir/connection.cc -build $outdir/content.o: compile $srcdir/content.cc -build $outdir/titan.o: compile $srcdir/titan.cc +rule test + command = python3 -m unittest discover -s tests -v + pool = console -build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o $outdir/content.o $outdir/connection.o +build $build_directory/maple.o: compile maple/maple.cc +build $build_directory/gemini.o: compile maple/gemini.cc +build $build_directory/titan.o: compile maple/titan.cc +build $build_directory/content.o: compile maple/content.cc +build $build_directory/connection.o: compile maple/connection.cc +build $build_directory/maple: link $build_directory/maple.o $build_directory/gemini.o $build_directory/titan.o $build_directory/content.o $build_directory/connection.o +build check: test $build_directory/maple -default $outdir/$name +default $build_directory/maple diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..3980cd7 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,32 @@ +import os +import pathlib +import shutil +import subprocess +import tempfile +import unittest + +class BuildTests(unittest.TestCase): + def test_header_changes_rebuild_dependants(self): + test_directory = pathlib.Path(__file__).resolve().parent + repository = test_directory.parent + + with tempfile.TemporaryDirectory(prefix='maple-build-') as directory: + workspace = pathlib.Path(directory) + + shutil.copytree(repository / 'maple', workspace / 'maple') + shutil.copyfile(repository / 'build.ninja', workspace / 'build.ninja') + subprocess.run(['ninja'], cwd=workspace, check=True, capture_output=True) + + unchanged = subprocess.check_output(['ninja', '-n'], cwd=workspace, text=True) + + self.assertIn('no work to do', unchanged) + os.utime(workspace / 'maple/content.hh', None) + + rebuild = subprocess.check_output(['ninja'], cwd=workspace, text=True) + + self.assertIn('maple/content.cc', rebuild) + self.assertIn('maple/gemini.cc', rebuild) + self.assertNotIn('maple/titan.cc', rebuild) + +if __name__ == '__main__': + unittest.main() From bc46642f8cfa6a36f6e29d71fb082baf48929eb5 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:50:36 +0000 Subject: [PATCH 08/19] build: test Docker images and run with explicit runtime dependencies --- .dockerignore | 8 ++++---- .github/workflows/check.yaml | 2 ++ Dockerfile | 35 +++++++++++++++-------------------- docker-compose.yaml | 8 +++++++- tests/test_build.py | 7 +++++-- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.dockerignore b/.dockerignore index dbc39fc..b848dce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ -# Ignore everything ** - -!maple +!maple/ +!maple/** +!tests/ +!tests/*.py !build.ninja - diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 0fd62b3..c682996 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -17,3 +17,5 @@ jobs: run: sudo apt-get update && sudo apt-get install -y clang ninja-build pkg-config libssl-dev - name: Build and Test run: ninja check + - name: Build and Test the Container Image + run: docker build --tag maple:test . diff --git a/Dockerfile b/Dockerfile index cce24e0..6709053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,27 @@ -FROM alpine:latest as environment +FROM alpine:3.23 AS builder -RUN apk update \ - && apk upgrade \ - && apk add --no-cache libstdc++ +RUN apk add --no-cache clang g++ ninja pkgconf openssl-dev openssl python3 -FROM environment as build_environment +WORKDIR /source -RUN apk add --no-cache \ - clang \ - ninja \ - alpine-sdk \ - openssl-dev \ - linux-headers +COPY build.ninja ./ +COPY maple ./maple +COPY tests ./tests -FROM build_environment as builder +RUN ninja check -WORKDIR /maple - -COPY ./ ./ +FROM alpine:3.23 -RUN ninja - -FROM environment +RUN apk add --no-cache libstdc++ libssl3 libcrypto3 \ + && addgroup -S -g 1000 maple \ + && adduser -S -u 1000 -G maple maple WORKDIR /maple -COPY --from=builder /maple/build/maple ./ +COPY --from=builder /source/build/maple /usr/local/bin/maple + +USER maple EXPOSE 1965 -ENTRYPOINT ["./maple"] +ENTRYPOINT ["maple"] diff --git a/docker-compose.yaml b/docker-compose.yaml index bee1edf..cc7d7a6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,13 @@ services: maple: + build: . + image: maple:local + user: "${MAPLE_UID:-1000}:${MAPLE_GID:-1000}" ports: - "1965:1965" volumes: - "./.maple:/maple/.maple" - image: "fuwn/maple:latest" + environment: + TITAN: "${TITAN:-0}" + TITAN_TOKEN: "${TITAN_TOKEN:-}" + TITAN_MAX_SIZE: "${TITAN_MAX_SIZE:-1024}" diff --git a/tests/test_build.py b/tests/test_build.py index 3980cd7..ede60c7 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -17,9 +17,12 @@ def test_header_changes_rebuild_dependants(self): shutil.copyfile(repository / 'build.ninja', workspace / 'build.ninja') subprocess.run(['ninja'], cwd=workspace, check=True, capture_output=True) - unchanged = subprocess.check_output(['ninja', '-n'], cwd=workspace, text=True) + build_directory = workspace / 'build' + outputs = list(build_directory.glob('*.o')) + [build_directory / 'maple'] + modification_times_nanoseconds = [output.stat().st_mtime_ns for output in outputs] - self.assertIn('no work to do', unchanged) + subprocess.run(['ninja'], cwd=workspace, check=True, capture_output=True) + self.assertEqual([output.stat().st_mtime_ns for output in outputs], modification_times_nanoseconds) os.utime(workspace / 'maple/content.hh', None) rebuild = subprocess.check_output(['ninja'], cwd=workspace, text=True) From e73861b840b017a2488c4564defbf97e006f7007 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:52:46 +0000 Subject: [PATCH 09/19] build: derive the Nix shell from the tested package --- .envrc | 10 +----- flake.lock | 17 --------- flake.nix | 102 ++++++++++++++++++++++------------------------------- shell.nix | 11 ------ 4 files changed, 44 insertions(+), 96 deletions(-) delete mode 100644 shell.nix diff --git a/.envrc b/.envrc index 7e4312b..3550a30 100644 --- a/.envrc +++ b/.envrc @@ -1,9 +1 @@ -#!/usr/bin/env bash - -if type -P lorri &>/dev/null; then - eval "$(lorri direnv)" -else - echo 'while direnv evaluated .envrc, could not find the command "lorri" [https://github.com/nix-community/lorri]' - - use flake -fi +use flake diff --git a/flake.lock b/flake.lock index 55d28d1..6c800aa 100644 --- a/flake.lock +++ b/flake.lock @@ -1,21 +1,5 @@ { "nodes": { - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, "flake-utils": { "inputs": { "systems": [ @@ -53,7 +37,6 @@ }, "root": { "inputs": { - "flake-compat": "flake-compat", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "systems": "systems" diff --git a/flake.nix b/flake.nix index b301e2d..3abb3f8 100644 --- a/flake.nix +++ b/flake.nix @@ -1,15 +1,8 @@ { - description = "A very simple static Gemini server, now with Titan support!"; - + description = "A small static Gemini server with optional Titan uploads"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs"; systems.url = "github:nix-systems/default"; - - flake-compat = { - url = "github:edolstra/flake-compat"; - flake = false; - }; - flake-utils = { url = "github:numtide/flake-utils"; inputs.systems.follows = "systems"; @@ -26,71 +19,62 @@ flake-utils.lib.eachDefaultSystem ( system: let - pkgs = import nixpkgs { inherit system; }; - - meta = with pkgs.lib; { - description = "A very simple static Gemini server, now with Titan support!"; - homepage = "https://github.com/gemrest/maple"; - license = licenses.gpl3Only; - maintainers = [ maintainers.Fuwn ]; - mainPackage = "maple"; - platforms = platforms.unix; + packageSet = import nixpkgs { inherit system; }; + maple = packageSet.clangStdenv.mkDerivation { + pname = "maple"; + version = "0.1.6"; + src = packageSet.lib.cleanSource ./.; + nativeBuildInputs = [ + packageSet.ninja + packageSet.pkg-config + ]; + buildInputs = [ packageSet.openssl ]; + nativeCheckInputs = [ + packageSet.python3 + packageSet.openssl + ]; + doCheck = true; + buildPhase = '' + runHook preBuild + ninja + runHook postBuild + ''; + checkPhase = '' + runHook preCheck + ninja check + runHook postCheck + ''; + installPhase = '' + runHook preInstall + mkdir -p $out/bin + cp build/maple $out/bin/maple + runHook postInstall + ''; + meta = with packageSet.lib; { + description = "A small static Gemini server with optional Titan uploads"; + homepage = "https://github.com/gemrest/maple"; + license = licenses.gpl3Only; + maintainers = [ maintainers.Fuwn ]; + mainProgram = "maple"; + platforms = platforms.unix; + }; }; - - maple = - with pkgs; - ( - if pkgs.stdenv.isDarwin then - pkgs.clangStdenvNoLibs - else - pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenvNoLibs - ).mkDerivation - { - inherit meta; - - name = "maple"; - version = "0.1.6"; - src = lib.cleanSource ./.; - - nativeBuildInputs = [ - ninja - clang - ]; - - buildInputs = [ - libressl.dev - ]; - - buildPhase = '' - mkdir -p $out/bin - ninja - ''; - - installPhase = '' - cp build/maple $out/bin/maple - ''; - }; in { packages = { inherit maple; - default = maple; }; - apps = { maple = { - inherit meta; - + inherit (maple) meta; type = "app"; program = "${maple}/bin/maple"; }; - default = self.apps.${system}.maple; }; - - devShells.default = import ./shell.nix { - inherit pkgs; + devShells.default = (packageSet.mkShell.override { stdenv = packageSet.clangStdenv; }) { + inputsFrom = [ maple ]; }; } ); diff --git a/shell.nix b/shell.nix deleted file mode 100644 index 6647728..0000000 --- a/shell.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - pkgs ? import { }, -}: -pkgs.mkShell { - buildInputs = with pkgs; [ - libressl_3_9 - ninja - clang_19 - (llvmPackages_19.clang-tools.override { enableLibcxx = true; }) - ]; -} From 884b1780b053e9cafb97af0ed582a304a46300b0 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:52:46 +0000 Subject: [PATCH 10/19] docs: replace obsolete usage guidance and protect existing keys --- README.md | 134 +++++++++++++++++++++-------------------------------- bin/genkey | 28 +++++------ 2 files changed, 68 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 95f0536..155979d 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,82 @@ # Maple -A very simple static Gemini server, now with Titan support! +A small static Gemini server with optional Titan uploads. -### Lines-of-code +## Build and Run -This codebase is now 351 lines of lines-of-code! +Maple requires a C++20 Clang compiler, Ninja, pkg-config, and OpenSSL development +libraries. Python 3 and the OpenSSL command-line tool are needed for tests. -The statement "... written within a single file and liberally -spanning 159 lines-of-code." has been removed. +For a new capsule: -If you would still like the minimal 159-LOC experience, checkout -[49ce0f8](https://github.com/gemrest/maple/tree/49ce0f83b8abd1af4760e56c1673c6997ef8a2c4)! -(Docker tag `0.1.4`) Do note, that version of this project is outdated and only -has Gemini support. - -## Usage - -### Docker Compose - -A Docker Compose file is already setup [here](./docker-compose.yaml). The only -field which should be modified is the `ports`, if need be. - -```shell -docker-compose up -d +```sh +ninja +ninja check +bin/genkey +printf '# Welcome\n' > .maple/gmi/index.gmi +build/maple ``` -### Docker - -```shell -docker run -p 1965:1965 -v ./your_capsules_directory:/maple/.maple fuwn/maple:latest -``` - -### Executable - -1. Build: `tup` (requires [Tup](https://gittup.org/tup/index.html)) -2. Run: `build/maple`, or - `TITAN=1 TITAN_TOKEN=secret TITAN_MAX_SIZE=2048 build/maple` - -### Certificates - -Before any possible method of usage; you must generate or use a preexisting -server certificate. - -If you do not have an existing certificate, generate one using the provided -certificate generations script: [`bin/genkey`](bin/genkey) (requires -[OpenSSL](https://www.openssl.org/)). +`ninja check` uses temporary content and certificates, and requires local port +1965 to be free. It checks request framing, filesystem containment, uploads, +connection deadlines, shutdown, and incremental builds. -The server certificate must be placed in a directory -- directly adjacent to the -Docker Compose file or executable -- named [`.maple`](.maple). The public and -private keys must be named `public.pem` and `private.pem`, respectively. +With Nix: -### Gemini Content - -Gemini content must be placed in a directory -- directly adjacent to the -Docker Compose file or executable -- named [`.maple/gmi`](.maple/gmi) and -ending with the file extension `.gmi`. - -### Environment Variables - -Maple can be configured with three optional environment variables: - -#### `TITAN` - -Enable Titan support. - -Takes a value of either `true` or `1`, case-insensitive. +```sh +nix develop +nix build +``` -Defaults to off. +The Nix package also runs the regression suite during its build. -#### `TITAN_TOKEN` +## Content and Certificates -Set a token to restrict Titan access. +All paths are relative to the server's **working directory**, not its executable: -Takes a string value, case-sensitive. +```text +.maple/ + public.pem + private.pem + gmi/ + index.gmi +``` -Defaults to no token, **anyone can upload!** +`bin/genkey` creates a self-signed certificate and refuses to overwrite existing +keys. You can instead provide your own certificate and matching private key. +Keep the private key readable only by the account running Maple. -#### `TITAN_MAX_SIZE` +Maple serves regular `.gmi` files; directory requests use `index.gmi`. Changes +become visible without a restart. Symlinks and `.` or `..` path components are +rejected. Content directories must already exist before uploading into them. -The maximum size in bytes which a Titan upload is permitted to perform. +## Titan -Takes an integer. +| Variable | Default | Meaning | +| --- | --- | --- | +| `TITAN` | Disabled | `1` or `true`, case-insensitive, enables uploads. | +| `TITAN_TOKEN` | Empty | Required upload token. An empty token permits unauthenticated uploads. | +| `TITAN_MAX_SIZE` | `1024` | Maximum upload bytes, written as an unsigned decimal integer. | -Default to `1024`. +Uploads require `mime=text/gemini`, a valid `size`, and a `.gmi` destination. +Completed uploads replace files atomically; incomplete uploads preserve them. +New files are private to the server account. -### Hacking +Maple serves one connection at a time. The TLS handshake and request header share +a five-second absolute deadline. Uploads and responses have a 15-second +inactivity timeout. -If you decide to tamper with Maple, you should give your commits a test. As -there isn't a test suite yet, feel free to use any of these examples: +## Docker -```bash -# Using OpenSSL directly to debug different requests: -$ openssl s_client -ign_eof -quiet -connect localhost:1965 <<< \ -> "gemini://localhost:1965" +Prepare `.maple` as above, then run: -# Using Bollux to debug proper requests... or any client! -$ bollux localhost +```sh +MAPLE_UID=$(id -u) MAPLE_GID=$(id -g) docker compose up --build -d ``` -## Capsules using Maple - -[Add yours!](https://github.com/gemrest/maple/edit/main/README.md) - -- () +The user and group overrides let the container read your private key and write +uploads without widening file permissions. Compose passes through the three +Titan variables; uploads remain disabled by default. ## License diff --git a/bin/genkey b/bin/genkey index e081711..0ebb245 100755 --- a/bin/genkey +++ b/bin/genkey @@ -1,18 +1,18 @@ #!/usr/bin/env bash -read \ - -rp \ - "Which common name (hostname) would you like to use for the generated key? " \ - common_name +set -euo pipefail +umask 077 -openssl req \ - -new \ - -subj "/CN=$common_name" \ - -x509 \ - -newkey ec \ +if [[ -e .maple/private.pem || -L .maple/private.pem || + -e .maple/public.pem || -L .maple/public.pem ]]; then + echo 'A certificate or key already exists in .maple; refusing to overwrite it.' >&2 + + exit 1 +fi + +mkdir -p .maple/gmi +read -r -p 'Hostname for the Certificate: ' common_name +openssl req -new -x509 -newkey ec \ -pkeyopt ec_paramgen_curve:prime256v1 \ - -days 365 \ - -nodes \ - -out .maple/public.pem \ - -keyout .maple/private.pem \ - -inform pem + -subj "/CN=$common_name" -days 365 -nodes \ + -out .maple/public.pem -keyout .maple/private.pem From c425dfb69c92423d3bd3d5588c63a7357bc06e2b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 09:54:30 +0000 Subject: [PATCH 11/19] fix: reject elapsed deadlines before polling --- maple/connection.cc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/maple/connection.cc b/maple/connection.cc index 4d12ac9..942ae21 100644 --- a/maple/connection.cc +++ b/maple/connection.cc @@ -22,6 +22,11 @@ auto Connection::retry(int result) -> bool { while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { const auto remaining = deadline_ - std::chrono::steady_clock::now(); + + if (remaining <= std::chrono::steady_clock::duration::zero()) { + return false; + } + const auto milliseconds = std::chrono::ceil(remaining); const int timeout_milliseconds = static_cast( From 1a649ee9d6b87947ee18eaf9210b8ccdf62b2366 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 00:48:32 +0000 Subject: [PATCH 12/19] chore: ignore macOS Finder metadata --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e347170..2a1004e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .idea .vscode +# macOS +.DS_Store + # Build Artifacts build From 0dd3cd366eac74649e85c03577e4a69f0d0a67dd Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 01:25:14 +0000 Subject: [PATCH 13/19] feat: support configurable listening ports and isolated tests --- README.md | 5 ++-- maple/maple.cc | 24 ++++++++++++++--- tests/test_server.py | 64 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 155979d..fdc6c1b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,8 @@ printf '# Welcome\n' > .maple/gmi/index.gmi build/maple ``` -`ninja check` uses temporary content and certificates, and requires local port -1965 to be free. It checks request framing, filesystem containment, uploads, -connection deadlines, shutdown, and incremental builds. +Maple listens on TCP port 1965 by default. Set `MAPLE_PORT` to an unsigned decimal +integer from 1 to 65535 to use another port, for example `MAPLE_PORT=1966 build/maple`. With Nix: diff --git a/maple/maple.cc b/maple/maple.cc index 791d75e..15bdc41 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -27,10 +27,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -41,6 +43,7 @@ namespace { volatile std::sig_atomic_t stopping = 0; struct Configuration { + std::uint16_t port = 1965; bool titan_enabled = false; std::string token; std::size_t maximum_upload_bytes = 1024; @@ -48,6 +51,19 @@ struct Configuration { auto configuration() -> std::optional { Configuration settings; + + if (const char *configured_port = std::getenv("MAPLE_PORT")) { + const auto port = maple::parse_size(configured_port); + + if (!port || *port == 0 || *port > std::numeric_limits::max()) { + std::cerr << "MAPLE_PORT must be an unsigned integer between 1 and 65535.\n"; + + return std::nullopt; + } + + settings.port = static_cast(*port); + } + const char *enabled = std::getenv("TITAN"); if (enabled == nullptr) { @@ -152,13 +168,13 @@ auto serve_request(maple::Connection &connection, bool titan_enabled, return connection.write("20 text/gemini\r\nUpload complete.\n"); } -auto listen_socket() -> maple::Descriptor { +auto listen_socket(std::uint16_t port) -> maple::Descriptor { maple::Descriptor listener(socket(AF_INET, SOCK_STREAM, 0)); sockaddr_in address{}; const int reuse_address = 1; address.sin_family = AF_INET; - address.sin_port = htons(1965); + address.sin_port = htons(port); address.sin_addr.s_addr = htonl(INADDR_ANY); if (listener.get() < 0 || @@ -168,7 +184,7 @@ auto listen_socket() -> maple::Descriptor { bind(listener.get(), reinterpret_cast(&address), sizeof(address)) < 0 || listen(listener.get(), SOMAXCONN) < 0) { - perror("Unable to listen on port 1965"); + perror("Unable to listen on the configured port"); return maple::Descriptor(-1); } @@ -221,7 +237,7 @@ auto main() -> int { return EXIT_FAILURE; } - const auto listener = listen_socket(); + const auto listener = listen_socket(settings->port); if (listener.get() < 0) { return EXIT_FAILURE; diff --git a/tests/test_server.py b/tests/test_server.py index 8689917..b93cf8f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -14,7 +14,6 @@ CONFIGURATION_TIMEOUT_SECONDS = 0.5 CONNECTION_TIMEOUT_SECONDS = 2 -DEFAULT_PORT = 1965 IDLE_CONNECTION_TIMEOUT_SECONDS = 1 PROCESS_EXIT_TIMEOUT_SECONDS = 2 QUEUED_REQUEST_TIMEOUT_SECONDS = 35 @@ -73,26 +72,32 @@ def setUp(self): def start(self, **settings): with socket.socket() as reservation: - reservation.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - reservation.bind(('127.0.0.1', DEFAULT_PORT)) + reservation.bind(('', 0)) - environment = {key: value for key, value in os.environ.items() if not key.startswith('TITAN')} + self.port = reservation.getsockname()[1] - environment.update(TITAN='1', TITAN_MAX_SIZE='8192') + environment = { + key: value for key, value in os.environ.items() + if not key.startswith('TITAN') and key != 'MAPLE_PORT' + } + + environment.update(TITAN='1', TITAN_MAX_SIZE='8192', MAPLE_PORT=str(self.port)) environment.update(settings) self.process = subprocess.Popen( [str(self.binary)], cwd=self.directory, env=environment, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) startup_deadline_seconds = time.monotonic() + STARTUP_TIMEOUT_SECONDS while time.monotonic() < startup_deadline_seconds: if self.process.poll() is not None: - self.fail(f'Server exited at startup: {self.process.returncode}') + error = self.process.stderr.read().decode(errors='replace') + + self.fail(f'Server exited at startup ({self.process.returncode}): {error}') try: - with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=STARTUP_PROBE_TIMEOUT_SECONDS): + with socket.create_connection(('127.0.0.1', self.port), timeout=STARTUP_PROBE_TIMEOUT_SECONDS): return except ConnectionRefusedError: time.sleep(STARTUP_POLL_INTERVAL_SECONDS) @@ -110,8 +115,10 @@ def stop(self): self.process.kill() self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + self.process.stderr.close() + def connect(self, timeout_seconds=CONNECTION_TIMEOUT_SECONDS): - connection = socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=timeout_seconds) + connection = socket.create_connection(('127.0.0.1', self.port), timeout=timeout_seconds) try: return self.context.wrap_socket(connection, server_hostname='localhost') @@ -147,6 +154,32 @@ def test_existing_file(self): self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), b'20 text/gemini\r\nORIGINAL') + def test_configured_port_without_titan(self): + self.start(TITAN='0') + self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), + b'20 text/gemini\r\nORIGINAL') + self.assertTrue(self.upload('/existing.gmi', b'CHANGED').startswith(b'59')) + + def test_invalid_configured_port(self): + for port in ('', '0', '-1', '65536', '+1965', '1965junk', ' 1965', + '99999999999999999999999999'): + with self.subTest(port=port): + environment = { + key: value for key, value in os.environ.items() + if not key.startswith('TITAN') and key != 'MAPLE_PORT' + } + + environment['MAPLE_PORT'] = port + + result = subprocess.run( + [str(self.binary)], cwd=self.directory, env=environment, + capture_output=True, timeout=CONFIGURATION_TIMEOUT_SECONDS, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'MAPLE_PORT must be an unsigned integer between 1 and 65535.', + result.stderr) + def test_upload_traversal(self): self.start() @@ -214,7 +247,7 @@ def test_upload_uses_declared_size_across_tls_records(self): outgoing = ssl.MemoryBIO() session = self.context.wrap_bio(incoming, outgoing, server_hostname='localhost') - with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=CONNECTION_TIMEOUT_SECONDS) as transport: + with socket.create_connection(('127.0.0.1', self.port), timeout=CONNECTION_TIMEOUT_SECONDS) as transport: while True: try: session.do_handshake() @@ -274,11 +307,14 @@ def test_empty_upload_ignores_surplus_bytes(self): def test_negative_configured_size(self): environment = {**os.environ, 'TITAN': '1', 'TITAN_MAX_SIZE': '-1'} + + environment.pop('MAPLE_PORT', None) + result = subprocess.run([str(self.binary)], cwd=self.directory, env=environment, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=CONFIGURATION_TIMEOUT_SECONDS) + capture_output=True, timeout=CONFIGURATION_TIMEOUT_SECONDS) self.assertNotEqual(result.returncode, 0) + self.assertIn(b'TITAN_MAX_SIZE must be an unsigned integer.', result.stderr) def test_fragmented_request_and_body(self): self.start() @@ -367,7 +403,7 @@ def test_nonregular_and_non_gemini_files(self): def test_idle_handshake_expires(self): self.start() - with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): + with socket.create_connection(('127.0.0.1', self.port), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): with self.connect(timeout_seconds=REQUEST_EXPIRY_TIMEOUT_SECONDS) as connection: connection.sendall(b'gemini://localhost/\r\n') self.assertIn(b'INDEX', self.read_response(connection)) @@ -430,7 +466,7 @@ def test_progressing_upload_outlives_request_deadline(self): def test_shutdown_during_handshake(self): self.start() - with socket.create_connection(('127.0.0.1', DEFAULT_PORT), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): + with socket.create_connection(('127.0.0.1', self.port), timeout=IDLE_CONNECTION_TIMEOUT_SECONDS): time.sleep(SHUTDOWN_SETTLE_SECONDS) self.process.send_signal(signal.SIGTERM) self.assertEqual(self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS), 0) From b0ca4a1e20ad26ad167b6bb4d13eb9b0f29396c4 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 01:25:22 +0000 Subject: [PATCH 14/19] build: refresh Nix dependencies and simplify the flake --- .gitignore | 1 + README.md | 3 +- flake.lock | 49 +++++---------- flake.nix | 178 +++++++++++++++++++++++++++++++++-------------------- 4 files changed, 129 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index 2a1004e..db0fd53 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ build .ninja_deps # Nix +.direnv/ result # Python Tests diff --git a/README.md b/README.md index fdc6c1b..9cb392b 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,10 @@ With Nix: ```sh nix develop +nix flake check nix build ``` -The Nix package also runs the regression suite during its build. - ## Content and Certificates All paths are relative to the server's **working directory**, not its executable: diff --git a/flake.lock b/flake.lock index 6c800aa..61eef25 100644 --- a/flake.lock +++ b/flake.lock @@ -1,60 +1,41 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": [ - "systems" - ] - }, + "nixpkgs": { "locked": { - "lastModified": 1726560853, - "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "lastModified": 1791487292, + "narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30", "type": "github" }, "original": { - "owner": "numtide", - "repo": "flake-utils", + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", "type": "github" } }, - "nixpkgs": { + "nixpkgs-intel-darwin": { "locked": { - "lastModified": 1727653192, - "narHash": "sha256-YBIXvJOLKU1FIQM0fB5lBLT1CoPpMCn03vHyNsVFXZ4=", + "lastModified": 1791447531, + "narHash": "sha256-AsoGS8G2y3shwAtQWQhgV4Fa3uw6qzFejfqSblOGkhE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c6f2346239c6f6abdc8befcb2a10759083c85f5d", + "rev": "55afc4c3adc7194df3b96d43b7d18bf8131bfcfa", "type": "github" }, "original": { "owner": "NixOS", + "ref": "nixpkgs-26.05-darwin", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "systems": "systems" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs-intel-darwin": "nixpkgs-intel-darwin" } } }, diff --git a/flake.nix b/flake.nix index 3abb3f8..8bb009b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,81 +1,127 @@ { description = "A small static Gemini server with optional Titan uploads"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs"; - systems.url = "github:nix-systems/default"; - flake-utils = { - url = "github:numtide/flake-utils"; - inputs.systems.follows = "systems"; - }; - }; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + inputs.nixpkgs-intel-darwin.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin"; outputs = { nixpkgs, - flake-utils, + nixpkgs-intel-darwin, self, - ... }: - flake-utils.lib.eachDefaultSystem ( - system: - let - packageSet = import nixpkgs { inherit system; }; - maple = packageSet.clangStdenv.mkDerivation { - pname = "maple"; - version = "0.1.6"; - src = packageSet.lib.cleanSource ./.; - nativeBuildInputs = [ - packageSet.ninja - packageSet.pkg-config - ]; - buildInputs = [ packageSet.openssl ]; - nativeCheckInputs = [ - packageSet.python3 - packageSet.openssl - ]; - doCheck = true; - buildPhase = '' - runHook preBuild - ninja - runHook postBuild - ''; - checkPhase = '' - runHook preCheck - ninja check - runHook postCheck - ''; - installPhase = '' - runHook preInstall - mkdir -p $out/bin - cp build/maple $out/bin/maple - runHook postInstall - ''; - meta = with packageSet.lib; { - description = "A small static Gemini server with optional Titan uploads"; - homepage = "https://github.com/gemrest/maple"; - license = licenses.gpl3Only; - maintainers = [ maintainers.Fuwn ]; - mainProgram = "maple"; - platforms = platforms.unix; + let + forEachSystem = nixpkgs.lib.genAttrs [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + packageSets = forEachSystem ( + system: + if system == "x86_64-darwin" then + nixpkgs-intel-darwin.legacyPackages.${system} + else + nixpkgs.legacyPackages.${system} + ); + in + { + packages = forEachSystem ( + system: + let + packageSet = packageSets.${system}; + maple = packageSet.clangStdenv.mkDerivation { + pname = "maple"; + version = "0.1.6"; + src = packageSet.lib.fileset.toSource { + root = ./.; + fileset = packageSet.lib.fileset.unions [ + ./LICENSE + ./build.ninja + ./maple + ./tests + ]; + }; + nativeBuildInputs = [ + packageSet.ninja + packageSet.pkg-config + ]; + buildInputs = [ packageSet.openssl ]; + nativeCheckInputs = [ + packageSet.python3 + packageSet.openssl + ]; + doCheck = true; + + buildPhase = '' + runHook preBuild + ninja + runHook postBuild + ''; + + checkPhase = '' + runHook preCheck + ninja check + runHook postCheck + ''; + + installPhase = '' + runHook preInstall + install -Dm755 build/maple $out/bin/maple + runHook postInstall + ''; + + meta = { + description = "A small static Gemini server with optional Titan uploads"; + homepage = "https://github.com/gemrest/maple"; + license = packageSet.lib.licenses.gpl3Only; + maintainers = [ packageSet.lib.maintainers.Fuwn ]; + mainProgram = "maple"; + platforms = packageSet.lib.platforms.unix; + }; }; - }; - in - { - packages = { + in + { inherit maple; + default = maple; - }; - apps = { + } + ); + + apps = forEachSystem ( + system: + let + package = self.packages.${system}.maple; maple = { - inherit (maple) meta; + inherit (package) meta; + type = "app"; - program = "${maple}/bin/maple"; + program = "${package}/bin/maple"; }; - default = self.apps.${system}.maple; - }; - devShells.default = (packageSet.mkShell.override { stdenv = packageSet.clangStdenv; }) { - inputsFrom = [ maple ]; - }; - } - ); + in + { + inherit maple; + + default = maple; + } + ); + + checks = forEachSystem (system: { + inherit (self.packages.${system}) maple; + }); + + devShells = forEachSystem ( + system: + let + packageSet = packageSets.${system}; + maple = self.packages.${system}.maple; + in + { + default = (packageSet.mkShell.override { stdenv = packageSet.clangStdenv; }) { + inputsFrom = [ maple ]; + }; + } + ); + + formatter = forEachSystem (system: packageSets.${system}.nixfmt); + }; } From 7e403b286b227540e8a81e64fc461fffaf65c3dc Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 07:01:03 +0000 Subject: [PATCH 15/19] fix: validate request URLs and decode content paths --- README.md | 6 +- build.ninja | 3 +- maple/content.cc | 29 +++---- maple/gemini.cc | 2 +- maple/gemini.hh | 4 +- maple/maple.cc | 32 ++------ maple/titan.cc | 2 +- maple/titan.hh | 4 +- maple/uri.cc | 191 +++++++++++++++++++++++++++++++++++++++++++ maple/uri.hh | 19 +++++ tests/test_server.py | 170 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 411 insertions(+), 51 deletions(-) create mode 100644 maple/uri.cc create mode 100644 maple/uri.hh diff --git a/README.md b/README.md index 9cb392b..1073e28 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,10 @@ keys. You can instead provide your own certificate and matching private key. Keep the private key readable only by the account running Maple. Maple serves regular `.gmi` files; directory requests use `index.gmi`. Changes -become visible without a restart. Symlinks and `.` or `..` path components are -rejected. Content directories must already exist before uploading into them. +become visible without a restart. URL path components are percent-decoded once; +queries do not affect file selection. Symlinks, encoded separators, NUL bytes, +and `.` or `..` path components are rejected. Content directories must already +exist before uploading into them. ## Titan diff --git a/build.ninja b/build.ninja index 8e3a7b5..07d1e7d 100644 --- a/build.ninja +++ b/build.ninja @@ -19,7 +19,8 @@ build $build_directory/gemini.o: compile maple/gemini.cc build $build_directory/titan.o: compile maple/titan.cc build $build_directory/content.o: compile maple/content.cc build $build_directory/connection.o: compile maple/connection.cc -build $build_directory/maple: link $build_directory/maple.o $build_directory/gemini.o $build_directory/titan.o $build_directory/content.o $build_directory/connection.o +build $build_directory/uri.o: compile maple/uri.cc +build $build_directory/maple: link $build_directory/maple.o $build_directory/gemini.o $build_directory/titan.o $build_directory/content.o $build_directory/connection.o $build_directory/uri.o build check: test $build_directory/maple default $build_directory/maple diff --git a/maple/content.cc b/maple/content.cc index c9a5fbb..4f4a3cc 100644 --- a/maple/content.cc +++ b/maple/content.cc @@ -1,4 +1,5 @@ #include "content.hh" +#include "uri.hh" #include #include #include @@ -20,34 +21,29 @@ auto resolve(std::string_view path) -> std::optional { return std::nullopt; } - std::string relative_path(path.substr(1)); - - if (relative_path.empty() || relative_path.back() == '/') { - relative_path += "index.gmi"; - } + path.remove_prefix(1); Descriptor directory(open(".maple/gmi", O_RDONLY | O_DIRECTORY | O_NOFOLLOW)); - std::size_t component_start_bytes = 0; if (directory.get() < 0) { return std::nullopt; } for (;;) { - const std::size_t separator_offset_bytes = - relative_path.find('/', component_start_bytes); - const std::string component = relative_path.substr( - component_start_bytes, separator_offset_bytes - component_start_bytes); + const std::size_t separator_offset_bytes = path.find('/'); + auto component = maple::uri::decode_segment( + path.empty() ? "index.gmi" : path.substr(0, separator_offset_bytes)); - if (component.empty() || component == "." || component == "..") { + if (!component || component->empty() || *component == "." || + *component == "..") { return std::nullopt; } if (separator_offset_bytes == std::string::npos) { - const std::size_t extension_start_bytes = component.find_last_of('.'); + const std::size_t extension_start_bytes = component->find_last_of('.'); std::string extension = extension_start_bytes == std::string::npos ? "" - : component.substr(extension_start_bytes); + : component->substr(extension_start_bytes); std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char character) { @@ -58,10 +54,10 @@ auto resolve(std::string_view path) -> std::optional { return std::nullopt; } - return Destination{std::move(directory), component}; + return Destination{std::move(directory), std::move(*component)}; } - const int child = openat(directory.get(), component.c_str(), + const int child = openat(directory.get(), component->c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW); if (child < 0) { @@ -69,7 +65,8 @@ auto resolve(std::string_view path) -> std::optional { } directory = Descriptor(child); - component_start_bytes = separator_offset_bytes + 1; + + path.remove_prefix(separator_offset_bytes + 1); } } } // namespace diff --git a/maple/gemini.cc b/maple/gemini.cc index e0815c3..08ff1ac 100644 --- a/maple/gemini.cc +++ b/maple/gemini.cc @@ -24,7 +24,7 @@ #include namespace maple::gemini { -auto handle_client(Connection &connection, const std::string &path) -> bool { +auto handle_client(Connection &connection, std::string_view path) -> bool { const auto file = content::open_read(path); if (file.get() < 0) { diff --git a/maple/gemini.hh b/maple/gemini.hh index b4bf591..2ddc078 100644 --- a/maple/gemini.hh +++ b/maple/gemini.hh @@ -22,11 +22,11 @@ #define GEMINI_HH #include "connection.hh" -#include +#include namespace maple { namespace gemini { -auto handle_client(Connection &connection, const std::string &path) -> bool; +auto handle_client(Connection &connection, std::string_view path) -> bool; } } // namespace maple diff --git a/maple/maple.cc b/maple/maple.cc index 15bdc41..8086d9c 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -23,6 +23,7 @@ #include "gemini.hh" #include "size.hh" #include "titan.hh" +#include "uri.hh" #include #include #include @@ -107,41 +108,20 @@ auto configuration() -> std::optional { auto serve_request(maple::Connection &connection, bool titan_enabled, const std::string &token, std::size_t maximum_upload_bytes) -> bool { - constexpr std::string_view titan_scheme = "titan://"; - constexpr std::string_view gemini_scheme = "gemini://"; const auto header = connection.read_header(); if (!header) { return connection.write("59 Invalid request header.\r\n"); } - const bool is_titan = header->starts_with(titan_scheme); - const std::size_t scheme_bytes = - is_titan ? titan_scheme.size() : gemini_scheme.size(); + const auto request = maple::uri::parse_request(*header); - if (!is_titan && !header->starts_with(gemini_scheme)) { - return connection.write("59 Unsupported scheme.\r\n"); - } - - const std::string_view address(*header); - const std::size_t path_start_bytes = address.find('/', scheme_bytes); - const std::string_view authority = - address.substr(scheme_bytes, path_start_bytes - scheme_bytes); - - if (authority.empty() || - address.find_first_of(" \t\r\n#") != std::string_view::npos) { + if (!request) { return connection.write("59 Invalid URL.\r\n"); } - const std::string path = path_start_bytes == std::string_view::npos - ? "/" - : std::string(address.substr(path_start_bytes)); - - if (!is_titan) { - const std::size_t query_start_bytes = path.find('?'); - const std::string content_path = path.substr(0, query_start_bytes); - - return maple::gemini::handle_client(connection, content_path); + if (!request->titan) { + return maple::gemini::handle_client(connection, request->path); } if (!titan_enabled) { @@ -149,7 +129,7 @@ auto serve_request(maple::Connection &connection, bool titan_enabled, } const auto upload = - maple::titan::parse_upload(path, token, maximum_upload_bytes); + maple::titan::parse_upload(request->path, token, maximum_upload_bytes); if (!upload) { return connection.write("59 Invalid Titan parameters or token.\r\n"); diff --git a/maple/titan.cc b/maple/titan.cc index 99453e3..5036879 100644 --- a/maple/titan.cc +++ b/maple/titan.cc @@ -31,7 +31,7 @@ auto parse_upload(std::string_view header, std::string_view token, return std::nullopt; } - const std::string path(header.substr(0, separator_offset_bytes)); + const std::string_view path = header.substr(0, separator_offset_bytes); std::optional size_bytes; std::optional mime; std::optional supplied_token; diff --git a/maple/titan.hh b/maple/titan.hh index 96aa87c..4ad50b9 100644 --- a/maple/titan.hh +++ b/maple/titan.hh @@ -23,15 +23,15 @@ #include #include -#include #include namespace maple::titan { struct Upload { - std::string path; + std::string_view path; std::size_t size_bytes; }; +// Keep the request header alive while using the returned upload path. auto parse_upload(std::string_view header, std::string_view token, std::size_t maximum_upload_bytes) -> std::optional; } // namespace maple::titan diff --git a/maple/uri.cc b/maple/uri.cc new file mode 100644 index 0000000..89bd90c --- /dev/null +++ b/maple/uri.cc @@ -0,0 +1,191 @@ +#include "uri.hh" +#include "size.hh" +#include +#include +#include +#include +#include + +namespace { +constexpr std::size_t encoded_byte_characters = 3; +constexpr std::string_view sub_delimiters = "!$&'()*+,;="; + +auto hex_value(char character) -> int { + if (character >= '0' && character <= '9') { + return character - '0'; + } + + if (character >= 'a' && character <= 'f') { + return character - 'a' + 10; + } + + if (character >= 'A' && character <= 'F') { + return character - 'A' + 10; + } + + return -1; +} + +auto is_unreserved(char character) -> bool { + return (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + std::string_view("-._~").find(character) != std::string_view::npos; +} + +auto valid_component(std::string_view text, std::string_view delimiters) + -> bool { + while (!text.empty()) { + if (text.front() == '%') { + if (text.size() < encoded_byte_characters || hex_value(text[1]) < 0 || + hex_value(text[2]) < 0) { + return false; + } + + text.remove_prefix(encoded_byte_characters); + + continue; + } + + if (!is_unreserved(text.front()) && + sub_delimiters.find(text.front()) == std::string_view::npos && + delimiters.find(text.front()) == std::string_view::npos) { + return false; + } + + text.remove_prefix(1); + } + + return true; +} + +auto valid_authority(std::string_view authority) -> bool { + if (authority.empty()) { + return false; + } + + const std::string_view host = authority.substr(0, authority.find(':')); + std::string_view suffix = authority.substr(host.size()); + + if (authority.front() == '[') { + const std::size_t closing_bracket_offset_bytes = authority.find(']'); + + if (closing_bracket_offset_bytes == std::string_view::npos) { + return false; + } + + const std::string literal( + authority.substr(1, closing_bracket_offset_bytes - 1)); + in6_addr address{}; + + if (inet_pton(AF_INET6, literal.c_str(), &address) != 1) { + return false; + } + + suffix = authority.substr(closing_bracket_offset_bytes + 1); + } else if (host.empty() || !valid_component(host, "")) { + return false; + } + + if (suffix.empty()) { + return true; + } + + if (suffix.front() != ':') { + return false; + } + + suffix.remove_prefix(1); + + if (suffix.empty()) { + return true; + } + + const auto port = maple::parse_size(suffix); + + return port && *port > 0 && + *port <= std::numeric_limits::max(); +} + +auto same_scheme(std::string_view scheme, std::string_view expected) -> bool { + return std::equal(scheme.begin(), scheme.end(), expected.begin(), + expected.end(), + [](unsigned char character, char lowercase) { + return std::tolower(character) == lowercase; + }); +} +} // namespace + +namespace maple::uri { +auto parse_request(std::string_view url) -> std::optional { + constexpr std::string_view scheme_separator = "://"; + const std::size_t scheme_end_bytes = url.find(scheme_separator); + const std::string_view scheme = url.substr(0, scheme_end_bytes); + const bool titan = same_scheme(scheme, "titan"); + + if (scheme_end_bytes == std::string_view::npos || + (!titan && !same_scheme(scheme, "gemini")) || + url.find('#') != std::string_view::npos) { + return std::nullopt; + } + + url.remove_prefix(scheme_end_bytes + scheme_separator.size()); + + const std::string_view authority = url.substr(0, url.find_first_of("/?#")); + + if (!valid_authority(authority)) { + return std::nullopt; + } + + url.remove_prefix(authority.size()); + + const std::size_t query_start_bytes = url.find('?'); + const std::string_view path = url.substr(0, query_start_bytes); + const std::string_view query = query_start_bytes == std::string_view::npos + ? "" + : url.substr(query_start_bytes + 1); + + if (!valid_component(path, ":@/") || !valid_component(query, ":@/?")) { + return std::nullopt; + } + + return Request{titan, path.empty() ? "/" : path}; +} + +auto decode_segment(std::string_view encoded) -> std::optional { + std::string decoded; + + decoded.reserve(encoded.size()); + + while (!encoded.empty()) { + char character = encoded.front(); + + if (character == '%') { + if (encoded.size() < encoded_byte_characters) { + return std::nullopt; + } + + const int high = hex_value(encoded[1]); + const int low = hex_value(encoded[2]); + + if (high < 0 || low < 0) { + return std::nullopt; + } + + character = static_cast(high * 16 + low); + + encoded.remove_prefix(encoded_byte_characters); + } else { + encoded.remove_prefix(1); + } + + if (character == '\0' || character == '/' || character == '\\') { + return std::nullopt; + } + + decoded += character; + } + + return decoded; +} +} // namespace maple::uri diff --git a/maple/uri.hh b/maple/uri.hh new file mode 100644 index 0000000..cc99126 --- /dev/null +++ b/maple/uri.hh @@ -0,0 +1,19 @@ +#ifndef MAPLE_URI_HH +#define MAPLE_URI_HH + +#include +#include +#include + +namespace maple::uri { +struct Request { + bool titan; + std::string_view path; +}; + +// Keep the request URL alive while using the returned path. +auto parse_request(std::string_view url) -> std::optional; +auto decode_segment(std::string_view encoded) -> std::optional; +} // namespace maple::uri + +#endif diff --git a/tests/test_server.py b/tests/test_server.py index b93cf8f..d9a513c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -154,6 +154,176 @@ def test_existing_file(self): self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), b'20 text/gemini\r\nORIGINAL') + def test_query_does_not_change_path(self): + self.start() + + for url, body in ( + ('gemini://localhost?next=/existing.gmi', b'INDEX'), + ('gemini://localhost/?next=/existing.gmi', b'INDEX'), + ('gemini://localhost/existing.gmi?next=/missing.gmi;size=1', b'ORIGINAL'), + ): + with self.subTest(url=url): + self.assertEqual(self.request(url.encode() + b'\r\n'), + b'20 text/gemini\r\n' + body) + + def test_case_insensitive_schemes(self): + self.start() + + for scheme in ('GEMINI', 'GeMiNi'): + with self.subTest(scheme=scheme): + self.assertEqual(self.request(f'{scheme}://localhost/existing.gmi\r\n'.encode()), + b'20 text/gemini\r\nORIGINAL') + + for scheme in ('TITAN', 'TiTaN'): + with self.subTest(scheme=scheme): + response = self.request( + f'{scheme}://localhost/new.gmi;size=4;mime=text/gemini\r\nDATA'.encode(), + ) + + self.assertTrue(response.startswith(b'20'), response) + self.assertEqual((self.content / 'new.gmi').read_bytes(), b'DATA') + + def test_valid_authorities(self): + self.start() + + for authority in ('localhost', 'LOCALHOST', '%6cocalhost', '127.0.0.1', + 'localhost:', f'localhost:{self.port}', 'localhost:65535', + '[::1]', f'[::1]:{self.port}', '[2001:db8::1]'): + with self.subTest(authority=authority): + self.assertEqual(self.request(f'gemini://{authority}/existing.gmi\r\n'.encode()), + b'20 text/gemini\r\nORIGINAL') + + def test_invalid_authorities(self): + self.start() + + for authority in ('', 'user@localhost', 'user:password@localhost', ':1965', + 'localhost:banana', 'localhost:-1', 'localhost:+1965', + 'localhost:0', 'localhost:65536', 'localhost:1965:1966', + '::1', '[::1', '[]', '[invalid]', '[::1]extra', + '[::1]:banana', 'local%host', 'local\\host'): + for scheme in ('gemini', 'titan'): + with self.subTest(authority=authority, scheme=scheme): + parameters = ';size=4;mime=text/gemini' if scheme == 'titan' else '' + header = f'{scheme}://{authority}/existing.gmi{parameters}\r\n' + response = self.request(header.encode()) + + self.assertTrue(response.startswith(b'59'), response) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + + def test_encoded_gemini_paths(self): + files = ( + ('space%20name.gmi', 'space name.gmi', b'SPACE'), + ('caf%C3%A9.gmi', 'café.gmi', b'UNICODE'), + ('part%3bsize=4.gmi', 'part;size=4.gmi', b'SEMICOLON'), + ('part%3Fnext.gmi', 'part?next.gmi', b'QUESTION'), + ('part%23name.gmi', 'part#name.gmi', b'HASH'), + ('a+b.gmi', 'a+b.gmi', b'PLUS'), + ('%2565xisting.gmi', '%65xisting.gmi', b'LITERAL'), + ('directory%20name/%65xisting%2Egmi', 'directory name/existing.gmi', b'NESTED'), + ) + + for _, name, body in files: + destination = self.content / name + + destination.parent.mkdir(exist_ok=True) + destination.write_bytes(body) + + self.start() + self.assertEqual(self.request(b'gemini://localhost/%65xisting.gmi\r\n'), + b'20 text/gemini\r\nORIGINAL') + + for encoded_path, _, body in files: + with self.subTest(path=encoded_path): + self.assertEqual(self.request(f'gemini://localhost/{encoded_path}\r\n'.encode()), + b'20 text/gemini\r\n' + body) + + def test_encoded_upload_paths(self): + (self.content / 'directory name').mkdir() + self.start() + + for encoded_path, name in ( + ('%65xisting.gmi', 'existing.gmi'), + ('space%20name.gmi', 'space name.gmi'), + ('caf%c3%a9.gmi', 'café.gmi'), + ('part%3Bsize=4.gmi', 'part;size=4.gmi'), + ('part%3fnext.gmi', 'part?next.gmi'), + ('part%23name.gmi', 'part#name.gmi'), + ('%2565xisting.gmi', '%65xisting.gmi'), + ('directory%20name/new.gmi', 'directory name/new.gmi'), + ): + with self.subTest(path=encoded_path): + response = self.upload('/' + encoded_path, b'DATA') + + self.assertTrue(response.startswith(b'20'), response) + self.assertEqual((self.content / name).read_bytes(), b'DATA') + self.assertEqual(self.request(f'gemini://localhost/{encoded_path}\r\n'.encode()), + b'20 text/gemini\r\nDATA') + + if encoded_path != name: + self.assertFalse((self.content / encoded_path).exists()) + + def test_titan_query_is_not_a_parameter(self): + self.start(TITAN_TOKEN='expected') + + response = self.request( + b'titan://localhost/existing.gmi;size=4;mime=text/gemini;token=expected' + b'?next=/missing.gmi;size=999;token=wrong\r\nDATA', + ) + + self.assertTrue(response.startswith(b'20'), response) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'DATA') + + response = self.request( + b'titan://localhost/existing.gmi?size=4;mime=text/gemini;token=expected\r\nDATA', + ) + + self.assertTrue(response.startswith(b'59'), response) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'DATA') + + def test_invalid_url_characters(self): + self.start() + + for path in (b'/existing.gmi%', b'/existing.gmi%1', b'/existing.gmi%XZ', + b'/existing.gmi?bad=%', b'/existing.gmi?bad=%1', + b'/existing.gmi#fragment', b'/existing.gmi?next=#fragment', + b'/existing.gmi\\', b'/existing.gmi[', b'/existing.gmi ', + b'/existing.gmi\t', b'/existing.gmi\x01', b'/existing.gmi\x7f'): + with self.subTest(path=path): + response = self.request(b'gemini://localhost' + path + b'\r\n') + + self.assertTrue(response.startswith(b'59'), response) + + def test_encoded_traversal_and_separators(self): + (self.content / 'folder').mkdir() + (self.content / 'folder/existing.gmi').write_bytes(b'NESTED') + self.start() + + for path in ('/%2e%2e/outside/index.gmi', '/.%2e/outside/index.gmi', + '/%2e./outside/index.gmi', '/%2E/existing.gmi', + '/folder%2fexisting.gmi', '/folder%2Fexisting.gmi', + '/folder%5cexisting.gmi', '/existing.gmi%00'): + with self.subTest(path=path): + self.assertFalse(self.request(f'gemini://localhost{path}\r\n'.encode()).startswith(b'20')) + self.assertFalse(self.upload(path, b'CHANGED').startswith(b'20')) + self.assertEqual((self.content / 'existing.gmi').read_bytes(), b'ORIGINAL') + self.assertEqual((self.content / 'folder/existing.gmi').read_bytes(), b'NESTED') + self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') + + self.assertEqual(sorted(file.relative_to(self.content).as_posix() + for file in self.content.rglob('*') if file.is_file()), + ['existing.gmi', 'folder/existing.gmi', 'index.gmi']) + + def test_encoded_symlinks(self): + (self.content / 'linked.gmi').symlink_to(self.directory / 'outside/index.gmi') + (self.content / 'linked').symlink_to(self.directory / 'outside', target_is_directory=True) + self.start() + + for path in ('/link%65d.gmi', '/link%65d/index.gmi'): + with self.subTest(path=path): + self.assertFalse(self.request(f'gemini://localhost{path}\r\n'.encode()).startswith(b'20')) + self.assertFalse(self.upload(path, b'CHANGED').startswith(b'20')) + self.assertEqual((self.directory / 'outside/index.gmi').read_bytes(), b'OUTSIDE') + def test_configured_port_without_titan(self): self.start(TITAN='0') self.assertEqual(self.request(b'gemini://localhost/existing.gmi\r\n'), From 7aff8e8bc1260644ac0eb54d2be682e02c9ff24d Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 07:03:11 +0000 Subject: [PATCH 16/19] refactor: centralise connection deadline checks --- maple/connection.cc | 16 ++++++++++------ maple/connection.hh | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/maple/connection.cc b/maple/connection.cc index 942ae21..a95405f 100644 --- a/maple/connection.cc +++ b/maple/connection.cc @@ -6,6 +6,10 @@ #include namespace maple { +auto Connection::can_continue() const -> bool { + return !stopping_ && std::chrono::steady_clock::now() < deadline_; +} + auto Connection::record_progress() -> void { if (transferring_) { deadline_ = std::chrono::steady_clock::now() + transfer_timeout; @@ -20,7 +24,7 @@ auto Connection::retry(int result) -> bool { return false; } - while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { + while (can_continue()) { const auto remaining = deadline_ - std::chrono::steady_clock::now(); if (remaining <= std::chrono::steady_clock::duration::zero()) { @@ -44,11 +48,11 @@ auto Connection::retry(int result) -> bool { // Poll's writability threshold can exceed the space needed for a small write. if (ready == 0 && error == SSL_ERROR_WANT_WRITE) { - return !stopping_; + return can_continue(); } if (ready > 0) { - return !stopping_ && (descriptor.revents & descriptor.events) != 0; + return can_continue() && (descriptor.revents & descriptor.events) != 0; } } @@ -70,7 +74,7 @@ auto Connection::handshake() -> bool { return false; } - while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { + while (can_continue()) { ERR_clear_error(); const int result = SSL_accept(tls_session_.get()); @@ -89,7 +93,7 @@ auto Connection::handshake() -> bool { auto Connection::receive(char *buffer, std::size_t size_bytes, std::size_t &byte_count) -> bool { - while (!stopping_ && std::chrono::steady_clock::now() < deadline_) { + while (can_continue()) { ERR_clear_error(); const int result = @@ -175,7 +179,7 @@ auto Connection::write(std::string_view response) -> bool { while (!response.empty()) { std::size_t byte_count = 0; - if (stopping_ || std::chrono::steady_clock::now() >= deadline_) { + if (!can_continue()) { return false; } diff --git a/maple/connection.hh b/maple/connection.hh index 7005b17..27c0e1e 100644 --- a/maple/connection.hh +++ b/maple/connection.hh @@ -27,6 +27,7 @@ public: auto write(std::string_view response) -> bool; private: + auto can_continue() const -> bool; auto record_progress() -> void; auto retry(int result) -> bool; auto receive(char *buffer, std::size_t size_bytes, std::size_t &byte_count) From aca43faa5b4ea1ace0ffcba8754cef5ea29d6d1b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 07:12:28 +0000 Subject: [PATCH 17/19] refactor: resolve strict compiler diagnostics --- build.ninja | 2 +- maple/content.cc | 2 +- maple/maple.cc | 2 +- maple/size.hh | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/build.ninja b/build.ninja index 07d1e7d..bb33942 100644 --- a/build.ninja +++ b/build.ninja @@ -3,7 +3,7 @@ compiler_flags = -std=c++20 -O2 -Weverything -Wno-padded -Wno-c++98-compat -Wno- build_directory = build rule compile - command = $compiler $compiler_flags $$(pkg-config --cflags openssl) -MMD -MF $out.d -c $in -o $out + command = $compiler $compiler_flags $$(pkg-config --cflags openssl | sed -E 's/(^| )-I/\1-isystem /g') -MMD -MF $out.d -c $in -o $out depfile = $out.d deps = gcc diff --git a/maple/content.cc b/maple/content.cc index 4f4a3cc..3d8af2d 100644 --- a/maple/content.cc +++ b/maple/content.cc @@ -86,7 +86,7 @@ auto open_read(std::string_view path) -> Descriptor { if (file.get() < 0 || fstat(file.get(), &status) < 0 || !S_ISREG(status.st_mode)) { - return Descriptor(-1); + file = Descriptor(-1); } return file; diff --git a/maple/maple.cc b/maple/maple.cc index 8086d9c..dea6944 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -166,7 +166,7 @@ auto listen_socket(std::uint16_t port) -> maple::Descriptor { listen(listener.get(), SOMAXCONN) < 0) { perror("Unable to listen on the configured port"); - return maple::Descriptor(-1); + listener = maple::Descriptor(-1); } return listener; diff --git a/maple/size.hh b/maple/size.hh index 67e482e..f1411dd 100644 --- a/maple/size.hh +++ b/maple/size.hh @@ -12,12 +12,11 @@ inline auto parse_size(std::string_view text) -> std::optional { return std::nullopt; } - const char *text_end = text.data() + text.size(); std::size_t value = 0; const auto [parsed_end, error] = - std::from_chars(text.data(), text_end, value); + std::from_chars(text.begin(), text.end(), value); - if (error != std::errc{} || parsed_end != text_end) { + if (error != std::errc{} || parsed_end != text.end()) { return std::nullopt; } From db50fff8e4f661df66ecc1dd5ca49b3b89ac79b6 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 08:02:06 +0000 Subject: [PATCH 18/19] build: scope platform warning exceptions --- build.ninja | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/build.ninja b/build.ninja index bb33942..bc58ae2 100644 --- a/build.ninja +++ b/build.ninja @@ -2,8 +2,20 @@ compiler = clang++ compiler_flags = -std=c++20 -O2 -Weverything -Wno-padded -Wno-c++98-compat -Wno-c++98-compat-pedantic build_directory = build +platform_warning_flags = $$(if test -f /etc/alpine-release; then $ + printf '%s ' '-Wno-disabled-macro-expansion'; $ + elif test "$$(uname -s)" = Darwin; then $ + case "$$($compiler --version)" in $ + ("Apple clang"*) $ + case "$$($compiler $compiler_flags -print-effective-triple)" in $ + ("$$(uname -m)"-apple-darwin*|"$$(uname -m)"-apple-macos*) $ + printf '%s ' '-Wno-poison-system-directories';; $ + esac;; $ + esac; $ + fi) + rule compile - command = $compiler $compiler_flags $$(pkg-config --cflags openssl | sed -E 's/(^| )-I/\1-isystem /g') -MMD -MF $out.d -c $in -o $out + command = $compiler $compiler_flags $platform_warning_flags $$(pkg-config --cflags openssl | sed -E 's/(^| )-I/\1-isystem /g') -MMD -MF $out.d -c $in -o $out depfile = $out.d deps = gcc From a09b9ee9d586118ddc513ee6cb9d797ff44f1e74 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 11 Oct 2026 09:07:45 +0000 Subject: [PATCH 19/19] chore: bump version to 0.2.0 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 8bb009b..66fe660 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ packageSet = packageSets.${system}; maple = packageSet.clangStdenv.mkDerivation { pname = "maple"; - version = "0.1.6"; + version = "0.2.0"; src = packageSet.lib.fileset.toSource { root = ./.; fileset = packageSet.lib.fileset.unions [