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/.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/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..c682996 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,21 @@ +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 + - name: Build and Test the Container Image + run: docker build --tag maple:test . diff --git a/.gitignore b/.gitignore index c3f4a1c..db0fd53 100644 --- a/.gitignore +++ b/.gitignore @@ -5,14 +5,19 @@ .idea .vscode -# Tup -.tup +# macOS +.DS_Store # Build Artifacts build # Ninja .ninja_log +.ninja_deps # Nix +.direnv/ result + +# Python Tests +__pycache__/ 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/README.md b/README.md index 95f0536..1073e28 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/)). +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`. -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. - -Defaults to off. +```sh +nix develop +nix flake check +nix 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. 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. -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/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/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 diff --git a/build.ninja b/build.ninja index 861ce91..bc58ae2 100644 --- a/build.ninja +++ b/build.ninja @@ -1,20 +1,38 @@ -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 + +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 = $cc $cxxflags -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 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/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 +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/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 $outdir/$name +default $build_directory/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/flake.lock b/flake.lock index 55d28d1..61eef25 100644 --- a/flake.lock +++ b/flake.lock @@ -1,77 +1,41 @@ { "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": [ - "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-compat": "flake-compat", - "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 b301e2d..66fe660 100644 --- a/flake.nix +++ b/flake.nix @@ -1,97 +1,127 @@ { - description = "A very simple static Gemini server, now with Titan support!"; - - 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"; - }; - }; + description = "A small static Gemini server with optional Titan uploads"; + 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 - 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; - }; - - 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 + 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.2.0"; + src = packageSet.lib.fileset.toSource { + root = ./.; + fileset = packageSet.lib.fileset.unions [ + ./LICENSE + ./build.ninja + ./maple + ./tests ]; - - buildPhase = '' - mkdir -p $out/bin - ninja - ''; - - installPhase = '' - cp build/maple $out/bin/maple - ''; }; - in - { - packages = { + 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 + { inherit maple; default = maple; - }; + } + ); - apps = { + apps = forEachSystem ( + system: + let + package = self.packages.${system}.maple; maple = { - inherit meta; + inherit (package) meta; type = "app"; - program = "${maple}/bin/maple"; + program = "${package}/bin/maple"; }; + in + { + inherit maple; - default = self.apps.${system}.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 ]; + }; + } + ); - devShells.default = import ./shell.nix { - inherit pkgs; - }; - } - ); + formatter = forEachSystem (system: packageSets.${system}.nixfmt); + }; } diff --git a/maple/connection.cc b/maple/connection.cc new file mode 100644 index 0000000..a95405f --- /dev/null +++ b/maple/connection.cc @@ -0,0 +1,211 @@ +#include "connection.hh" +#include +#include +#include +#include +#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; + } +} + +auto Connection::retry(int result) -> bool { + constexpr int maximum_poll_interval_milliseconds = 250; + const int error = SSL_get_error(tls_session_.get(), result); + + if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) { + return false; + } + + while (can_continue()) { + 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( + 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; + } + + // Poll's writability threshold can exceed the space needed for a small write. + if (ready == 0 && error == SSL_ERROR_WANT_WRITE) { + return can_continue(); + } + + if (ready > 0) { + return can_continue() && (descriptor.revents & descriptor.events) != 0; + } + } + + return false; +} + +Connection::~Connection() { + if (tls_session_ && SSL_is_init_finished(tls_session_.get())) { + SSL_shutdown(tls_session_.get()); + } +} + +auto Connection::handshake() -> bool { + 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 (can_continue()) { + ERR_clear_error(); + + const int result = SSL_accept(tls_session_.get()); + + 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 (can_continue()) { + ERR_clear_error(); + + const int result = + SSL_read_ex(tls_session_.get(), 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; + std::string header; + + while (header.size() < maximum_url_bytes + header_terminator_bytes) { + char character = '\0'; + std::size_t byte_count = 0; + + if (!receive(&character, 1, byte_count)) { + return std::nullopt; + } + + if (character == '\n') { + if (header.empty() || header.back() != '\r') { + return std::nullopt; + } + + header.pop_back(); + + transferring_ = true; + + record_progress(); + + 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 (!receive(buffer.data(), remaining_bytes, byte_count)) { + 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 (!can_continue()) { + 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_.get(), 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; +} +} // namespace maple diff --git a/maple/connection.hh b/maple/connection.hh new file mode 100644 index 0000000..27c0e1e --- /dev/null +++ b/maple/connection.hh @@ -0,0 +1,47 @@ +#ifndef MAPLE_CONNECTION_HH +#define MAPLE_CONNECTION_HH + +#include "descriptor.hh" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace maple { +class Connection { +public: + 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; + 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) + -> 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; + Descriptor socket_; + std::unique_ptr tls_session_; + const volatile std::sig_atomic_t &stopping_; + std::chrono::steady_clock::time_point deadline_; + bool transferring_ = false; +}; +} // namespace maple + +#endif diff --git a/maple/content.cc b/maple/content.cc new file mode 100644 index 0000000..3d8af2d --- /dev/null +++ b/maple/content.cc @@ -0,0 +1,155 @@ +#include "content.hh" +#include "uri.hh" +#include +#include +#include +#include +#include +#include + +namespace { +using maple::Descriptor; + +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; + } + + path.remove_prefix(1); + + Descriptor directory(open(".maple/gmi", O_RDONLY | O_DIRECTORY | O_NOFOLLOW)); + + if (directory.get() < 0) { + return std::nullopt; + } + + for (;;) { + 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 || 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), std::move(*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); + + path.remove_prefix(separator_offset_bytes + 1); + } +} +} // namespace + +namespace maple::content { +auto open_read(std::string_view path) -> Descriptor { + auto destination = resolve(path); + + if (!destination) { + return Descriptor(-1); + } + + 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)) { + file = Descriptor(-1); + } + + return file; +} + +auto write(std::string_view path, std::string_view body) -> bool { + auto destination = resolve(path); + + if (!destination) { + return false; + } + + struct stat status{}; + const int existing = + fstatat(destination->directory.get(), destination->name.c_str(), &status, + AT_SYMLINK_NOFOLLOW); + + 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()); + + if (byte_count < 0 && errno == EINTR) { + continue; + } + + if (byte_count <= 0) { + complete = false; + + break; + } + + body.remove_prefix(static_cast(byte_count)); + } + + 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/content.hh b/maple/content.hh new file mode 100644 index 0000000..b6b3f94 --- /dev/null +++ b/maple/content.hh @@ -0,0 +1,14 @@ +#ifndef MAPLE_CONTENT_HH +#define MAPLE_CONTENT_HH + +#include "descriptor.hh" +#include +#include +#include + +namespace maple::content { +auto open_read(std::string_view path) -> Descriptor; +auto write(std::string_view path, std::string_view body) -> bool; +} // namespace maple::content + +#endif 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 010e844..08ff1ac 100644 --- a/maple/gemini.cc +++ b/maple/gemini.cc @@ -18,34 +18,45 @@ * SPDX-License-Identifier: GPL-3.0-only */ -#include -#include -#include -#include -#include -#include - #include "gemini.hh" +#include "content.hh" +#include +#include 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(Connection &connection, std::string_view path) -> bool { + const auto file = content::open_read(path); + + if (file.get() < 0) { + return connection.write("51 File not found.\r\n"); + } + + if (!connection.write("20 text/gemini\r\n")) { + return false; } - std::cout << "requested " << path << '\n'; + 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 8a7796c..2ddc078 100644 --- a/maple/gemini.hh +++ b/maple/gemini.hh @@ -21,13 +21,12 @@ #ifndef GEMINI_HH #define GEMINI_HH -#include -#include +#include "connection.hh" +#include namespace maple { namespace gemini { -auto handle_client(const std::vector &, std::string, - std::stringstream &) -> void; +auto handle_client(Connection &connection, std::string_view path) -> bool; } } // namespace maple diff --git a/maple/maple.cc b/maple/maple.cc index 426cc14..dea6944 100644 --- a/maple/maple.cc +++ b/maple/maple.cc @@ -18,314 +18,247 @@ * SPDX-License-Identifier: GPL-3.0-only */ +#include "connection.hh" +#include "content.hh" +#include "gemini.hh" +#include "size.hh" +#include "titan.hh" +#include "uri.hh" #include -#include +#include #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" +struct Configuration { + std::uint16_t port = 1965; + bool titan_enabled = false; + std::string token; + std::size_t maximum_upload_bytes = 1024; +}; -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) { - return EXIT_FAILURE; - } +auto configuration() -> std::optional { + Configuration settings; + + if (const char *configured_port = std::getenv("MAPLE_PORT")) { + const auto port = maple::parse_size(configured_port); - // 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()); + 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); } - // Inform user of which files will be served - for (const std::string &file : gemini_files) { - std::cout << "serving " << file << '\n'; + const char *enabled = std::getenv("TITAN"); + + if (enabled == nullptr) { + return settings; } - // Setup SSL - if (maple::setup_ssl() == EXIT_FAILURE) { - return EXIT_FAILURE; + 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; } - // Listen and serve connections - for (;;) { - sockaddr_in socket_address_{}; - unsigned int socket_address_length = sizeof(socket_address_); - SSL *ssl; - const int client = accept(maple::maple_socket, - reinterpret_cast(&socket_address_), - &socket_address_length); + if (const char *token = std::getenv("TITAN_TOKEN")) { + settings.token = token; + } - if (client < 0) { - return maple::prepare_exit_with("unable to accept", false); - } + 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); - ssl = SSL_new(maple::ssl_context); - - SSL_set_fd(ssl, client); - - if (SSL_accept(ssl) <= 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; - } - - 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(gemini_files, 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, - titan_max_size); - } - } - - 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 { - ERR_print_errors_fp(stderr); - } + if (!parsed_maximum_upload_bytes) { + std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n"; + + return std::nullopt; } - SSL_shutdown(ssl); - SSL_free(ssl); - close(client); + settings.maximum_upload_bytes = *parsed_maximum_upload_bytes; } + + return settings; } -namespace maple { -auto prepare_exit_with(const char *message, bool ssl) -> int { - perror(message); +auto serve_request(maple::Connection &connection, bool titan_enabled, + const std::string &token, std::size_t maximum_upload_bytes) + -> bool { + const auto header = connection.read_header(); - if (ssl) { - ERR_print_errors_fp(stderr); + if (!header) { + return connection.write("59 Invalid request header.\r\n"); } - return EXIT_FAILURE; -} - -auto setup_environment(bool &titan, std::string &titan_token, - std::size_t &titan_max_size) -> int { - char *titan_environment = std::getenv("TITAN"); + const auto request = maple::uri::parse_request(*header); - if (titan_environment == nullptr) { - titan = false; - } else { - std::string valid_titan_environment(titan_environment); + if (!request) { + return connection.write("59 Invalid URL.\r\n"); + } - std::transform( - valid_titan_environment.begin(), valid_titan_environment.end(), - valid_titan_environment.begin(), - [](unsigned char character) -> int { return std::tolower(character); }); + if (!request->titan) { + return maple::gemini::handle_client(connection, request->path); + } - 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"); + if (!titan_enabled) { + return connection.write("59 Titan is disabled.\r\n"); + } - if (unvalidated_titan_token == nullptr) { - titan_token = ""; - } else { - titan_token = std::string(unvalidated_titan_token); - } + const auto upload = + maple::titan::parse_upload(request->path, token, maximum_upload_bytes); - if (unvalidated_titan_max_size == nullptr) { - constexpr std::size_t TITAN_MAX_SIZE = 1024; + if (!upload) { + return connection.write("59 Invalid Titan parameters or token.\r\n"); + } - titan_max_size = TITAN_MAX_SIZE; + const auto body = connection.read_body(upload->size_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); - } - } + if (!body) { + return connection.write("59 Incomplete upload.\r\n"); + } - titan = true; - } + if (!maple::content::write(upload->path, *body)) { + return connection.write("50 Unable to write content.\r\n"); } - return 0; + return connection.write("20 text/gemini\r\nUpload complete.\n"); } -auto setup_ssl() -> int { - sockaddr_in socket_address{}; +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(port); + 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 the configured port"); + + listener = maple::Descriptor(-1); + } - // Setup OpenSSL - SSL_library_init(); - SSL_load_error_strings(); + return listener; +} +} // namespace - maple::ssl_context = SSL_CTX_new(TLS_server_method()); +auto main() -> int { + const auto settings = configuration(); - if (maple::ssl_context == nullptr) { - return maple::prepare_exit_with("unable to create ssl context", true); + if (!settings) { + return EXIT_FAILURE; } - 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); - } + struct sigaction action{}; - 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); - } + action.sa_handler = [](int) { stopping = 1; }; + + sigemptyset(&action.sa_mask); - constexpr std::size_t GEMINI_PORT = 1965; + if (sigaction(SIGINT, &action, nullptr) < 0 || + sigaction(SIGTERM, &action, nullptr) < 0) { + perror("Unable to install shutdown handlers"); - socket_address.sin_family = AF_INET; - socket_address.sin_port = htons(GEMINI_PORT); - socket_address.sin_addr.s_addr = htonl(INADDR_ANY); + return EXIT_FAILURE; + } - maple::maple_socket = socket(AF_INET, SOCK_STREAM, 0); + action.sa_handler = SIG_IGN; - if (maple::maple_socket < 0) { - return maple::prepare_exit_with("unable to create socket", false); + if (sigaction(SIGPIPE, &action, nullptr) < 0) { + perror("Unable to ignore SIGPIPE"); + + return EXIT_FAILURE; } - // 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; + 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); - 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); + return EXIT_FAILURE; } - if (bind(maple::maple_socket, reinterpret_cast(&socket_address), - sizeof(socket_address)) < 0) { - return maple::prepare_exit_with("unable to bind", false); + const auto listener = listen_socket(settings->port); + + if (listener.get() < 0) { + return EXIT_FAILURE; } - if (listen(maple::maple_socket, 1) < 0) { - return maple::prepare_exit_with("unable to listen", false); + 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); + + if (ready < 0 && errno != EINTR) { + perror("Unable to wait for a connection"); + + return EXIT_FAILURE; + } + + if (ready <= 0 || stopping) { + continue; + } + + const int client = accept(listener.get(), nullptr, nullptr); + + if (client < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR || + errno == ECONNABORTED) { + continue; + } + + perror("Unable to accept a connection"); + + return EXIT_FAILURE; + } + + maple::Connection connection(tls_context.get(), client, stopping); + + 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/maple/size.hh b/maple/size.hh new file mode 100644 index 0000000..f1411dd --- /dev/null +++ b/maple/size.hh @@ -0,0 +1,27 @@ +#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; + } + + std::size_t value = 0; + const auto [parsed_end, error] = + std::from_chars(text.begin(), 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 2021903..5036879 100644 --- a/maple/titan.cc +++ b/maple/titan.cc @@ -18,153 +18,71 @@ * SPDX-License-Identifier: GPL-3.0-only */ -#include -#include -#include -#include -#include -#include - #include "titan.hh" +#include "size.hh" namespace maple::titan { -auto parameters_to_map(const std::vector ¶meters) - -> std::map { - std::map parameters_map; - - 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); +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(';'); - // 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); - // parameters.push_back(update_path); - path.erase(0, delimiter_position + 1); // Remove path from `path` - - // Find mime parameter in `path` - delimiter_position = path.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)); - - // Find token parameter in `path` - delimiter_position = path.find(';'); - - // 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)); - } - - /// 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; - } - ); - }; */ - - std::map parameters_map = - maple::titan::parameters_to_map(parameters); + const std::string_view path = header.substr(0, separator_offset_bytes); + std::optional size_bytes; + std::optional mime; + std::optional supplied_token; - // 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; - } + header.remove_prefix(separator_offset_bytes + 1); - if (parameters_map.find("size") == parameters_map.end()) { - response << "20 text/gemini\r\nThe serve (Maple) did not " - "receive a size parameter!"; + 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('='); - break; + if (value_separator_offset_bytes == std::string_view::npos) { + return std::nullopt; } - 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!"; + const std::string_view key = + parameter.substr(0, value_separator_offset_bytes); + const std::string_view value = + parameter.substr(value_separator_offset_bytes + 1); - break; - } - - try { - const std::size_t body_size = - static_cast(std::stoi(parameters_map.at("size"))); + if (key == "size" && !size_bytes) { + size_bytes = parse_size(value); - 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 << ")."; - - break; + if (!size_bytes || *size_bytes > maximum_upload_bytes) { + return std::nullopt; } - } catch (...) { - response << "20 text/gemini\r\nThe server (Maple) could not interpret " - "the size parameter as an integer!"; - - break; + } else if (key == "mime" && !mime) { + mime = value; + } else if (key == "token" && !supplied_token) { + supplied_token = value; + } else { + return std::nullopt; } - if (update_path == "/") { - update_path = "/index.gmi"; + if (next_parameter_offset_bytes == std::string_view::npos) { + break; } - const auto token_iterator = parameters_map.find("token"); - const std::string token = - token_iterator == parameters_map.end() ? "" : token_iterator->second; - - if (token == titan_token) { - std::ofstream file(".maple/gmi" + update_path); + header.remove_prefix(next_parameter_offset_bytes + 1); - file << body; - - 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 (header.empty()) { + return std::nullopt; } + } - break; + if (!size_bytes || !mime || *mime != "text/gemini" || + supplied_token.value_or("") != token) { + return std::nullopt; } + + return Upload{path, *size_bytes}; } } // namespace maple::titan diff --git a/maple/titan.hh b/maple/titan.hh index e8cd8b1..4ad50b9 100644 --- a/maple/titan.hh +++ b/maple/titan.hh @@ -21,19 +21,19 @@ #ifndef TITAN_HH #define TITAN_HH -#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_view path; + std::size_t size_bytes; +}; -auto handle_client(std::stringstream &, std::string, const std::string &, - std::size_t) -> void; -} // namespace titan -} // namespace maple +// 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 -#endif // TITAN_HH +#endif 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/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; }) - ]; -} diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..ede60c7 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,35 @@ +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) + + 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] + + 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) + + self.assertIn('maple/content.cc', rebuild) + self.assertIn('maple/gemini.cc', rebuild) + self.assertNotIn('maple/titan.cc', rebuild) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..d9a513c --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,714 @@ +"""Run with MAPLE_BINARY pointing to the executable under test.""" + +import os +import pathlib +import select +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 +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 +TRANSFER_EXPIRY_TIMEOUT_SECONDS = 17 + +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.bind(('', 0)) + + self.port = reservation.getsockname()[1] + + 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.PIPE, + ) + startup_deadline_seconds = time.monotonic() + STARTUP_TIMEOUT_SECONDS + + while time.monotonic() < startup_deadline_seconds: + if self.process.poll() is not None: + 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', self.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) + + self.process.stderr.close() + + def connect(self, timeout_seconds=CONNECTION_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') + 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) + + return self.read_response(connection) + + @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}' + + 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') + + 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'), + 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() + + 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')) + + def test_index_traversal(self): + self.start() + self.assertNotIn(b'OUTSIDE', self.request(b'gemini://localhost/../../outside/\r\n')) + + 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') + + 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') + + def test_missing_index(self): + self.start() + self.assertTrue(self.request(b'gemini://localhost/missing/\r\n').startswith(b'51')) + + 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')) + + def test_strict_upload_size(self): + self.start(TITAN_MAX_SIZE='4') + + 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', self.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'} + + environment.pop('MAPLE_PORT', None) + + 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'TITAN_MAX_SIZE must be an unsigned integer.', result.stderr) + + 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') + + 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') + + 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) + + 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') + + 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')) + + def test_idle_handshake_expires(self): + self.start() + + 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)) + + def test_slow_header_has_absolute_deadline(self): + 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): + closed = True + + 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')) + + 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') + + def test_shutdown_during_handshake(self): + self.start() + + 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) + + 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) + self.assertEqual(self.process.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS), 0) + +if __name__ == '__main__': + unittest.main()