feat: implement Smol Mail server in Rust with nix flake deployment

This commit is contained in:
randogoth 2026-09-26 11:52:22 +03:00
commit 71ad7e04c0
14 changed files with 2592 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
*.db
*.db-wal
*.db-shm
server.key
result
result-*

1004
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "bunshin"
version = "0.1.0"
edition = "2021"
description = "Smol Mail server"
[[bin]]
name = "bunshin"
path = "src/main.rs"
[dependencies]
snow = "0.9"
rusqlite = { version = "0.32", features = ["bundled"] }
ed25519-dalek = "2"
x25519-dalek = { version = "2", features = ["static_secrets"] }
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
data-encoding = "2"
clap = { version = "4", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
anyhow = "1"

67
README.md Normal file
View file

@ -0,0 +1,67 @@
# 分身 bunshin
A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client.
The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived.
The flake's main purpose is turnkey deployment on a NixOS host: import `nixosModules.default`, point it at a key, and `nixos-rebuild switch`.
## Deploying on NixOS
Add bunshin as a flake input and import the module:
```nix
{
inputs.bunshin.url = "https://code.randogoth.com/randogoth/bunshin";
outputs = { self, nixpkgs, bunshin, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
bunshin.nixosModules.default
{
services.bunshin = {
enable = true;
keyFile = "/var/lib/bunshin/server.key"; # provisioned out of band, see below
openFirewall = true;
inviteTokenFile = "/run/secrets/bunshin-invite"; # or inviteToken directly
};
}
];
};
};
}
```
`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `retentionDays`, `rateConnections` and `rateSends`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key.
Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`:
```
nix run "https://code.randogoth.com/randogoth/bunshin" -- keygen --key server.key
```
`keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake.
## Building
Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed.
```
cargo build --release
cargo test
```
The binary lands at `target/release/bunshin`. With Nix, skip the toolchain setup entirely: `nix build` produces the same binary at `./result/bin/bunshin`.
Running directly, outside the NixOS module:
```
bunshin keygen --key server.key
bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961
```
`serve` accepts `--max-envelope`, `--quota`, `--retention-days`, `--invite-token`, `--rate-connections` and `--rate-sends` to control size limits, per-mailbox quota, message retention, registration gating and abuse control. Run `bunshin serve --help` for defaults.
## Status
Implements SPEC.md version 1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER` (including invite tokens and key rotation chains). Verified end-to-end against the Python reference client over a live Noise connection, plus a raw-protocol test suite covering the rejection paths (auth failures, unknown users, malformed and oversized envelopes, rate limiting).

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1790323409,
"narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

180
flake.nix Normal file
View file

@ -0,0 +1,180 @@
{
description = "bunshin - Smol Mail server (Rust)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
bunshin = pkgs.rustPlatform.buildRustPackage {
pname = "bunshin";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.sqlite ];
};
in
{
packages.default = bunshin;
apps.default = flake-utils.lib.mkApp {
drv = bunshin;
name = "bunshin";
};
devShells.default = pkgs.mkShell {
packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ];
};
}) // {
nixosModules.default = { config, lib, pkgs, ... }:
let
cfg = config.services.bunshin;
inherit (lib) mkEnableOption mkOption mkIf types;
in
{
options.services.bunshin = {
enable = mkEnableOption "the bunshin Smol Mail server";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.system}.default;
description = "bunshin package to run.";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 1961;
description = "TCP port to listen on.";
};
keyFile = mkOption {
type = types.path;
description = ''
Path to the server's static Noise X25519 private key
(32 raw bytes, generated with `bunshin keygen`). Provisioned
out of band; this module does not generate it.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/bunshin";
description = "Directory holding mail.db.";
};
maxEnvelope = mkOption {
type = types.ints.positive;
default = 1048576;
description = "Maximum accepted envelope size, in bytes.";
};
quota = mkOption {
type = types.ints.positive;
default = 67108864;
description = "Per-mailbox storage quota, in bytes.";
};
retentionDays = mkOption {
type = types.ints.positive;
default = 30;
description = "Days a message is retained before being purged.";
};
rateConnections = mkOption {
type = types.ints.positive;
default = 120;
description = "Max accepted connections per minute, per source IP.";
};
rateSends = mkOption {
type = types.ints.positive;
default = 60;
description = "Max SEND operations per minute, per source IP.";
};
inviteToken = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Registration invite token. Null means open registration.
Prefer `inviteTokenFile` to avoid storing the token in the
world-readable Nix store.
'';
};
inviteTokenFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file (readable by the service via LoadCredential)
containing the registration invite token.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open the configured TCP port in the firewall.";
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !(cfg.inviteToken != null && cfg.inviteTokenFile != null);
message = "services.bunshin: set only one of inviteToken or inviteTokenFile.";
}
];
systemd.services.bunshin = {
description = "bunshin Smol Mail server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = pkgs.writeShellScript "bunshin-serve" ''
set -euo pipefail
args=(
serve
--key ${cfg.keyFile}
--db ${cfg.dataDir}/mail.db
--host ${cfg.host}
--port ${toString cfg.port}
--max-envelope ${toString cfg.maxEnvelope}
--quota ${toString cfg.quota}
--retention-days ${toString cfg.retentionDays}
--rate-connections ${toString cfg.rateConnections}
--rate-sends ${toString cfg.rateSends}
)
${lib.optionalString (cfg.inviteToken != null)
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
${lib.optionalString (cfg.inviteTokenFile != null)
''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''}
exec ${cfg.package}/bin/bunshin "''${args[@]}"
'';
DynamicUser = true;
StateDirectory = "bunshin";
StateDirectoryMode = "0700";
Restart = "on-failure";
} // lib.optionalAttrs (cfg.inviteTokenFile != null) {
LoadCredential = "invite-token:${cfg.inviteTokenFile}";
};
};
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
};
};
};
}

122
src/channel.rs Normal file
View file

@ -0,0 +1,122 @@
//! Noise_NX handshake and the framed transport on top of it.
//!
//! Two independent layers: Noise messages under a u16 length prefix, and
//! application frames (u32 length || u8 op || body) split across as many
//! Noise messages as they need and reassembled from them.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use snow::{Builder, TransportState};
use crate::proto::{ProtocolError, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE};
/// Runs the Noise_NX responder handshake. The initiator stays anonymous;
/// only we hold a static key. Returns the transport state and the
/// handshake hash (needed later to verify AUTH frames).
pub fn handshake(
stream: &mut TcpStream,
static_key: &[u8],
) -> anyhow::Result<(TransportState, Vec<u8>)> {
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
let mut noise = Builder::new(params)
.local_private_key(static_key)
.prologue(PROLOGUE)
.build_responder()?;
let mut buf = [0u8; 65535];
let mut msg = [0u8; 65535];
let len = read_u16_len(stream)?;
read_exact_into(stream, &mut buf[..len])?;
noise.read_message(&buf[..len], &mut msg)?;
let len = noise.write_message(&[], &mut buf)?;
write_u16_len(stream, &buf[..len])?;
anyhow::ensure!(noise.is_handshake_finished(), "handshake did not complete");
let hash = noise.get_handshake_hash().to_vec();
let transport = noise.into_transport_mode()?;
Ok((transport, hash))
}
fn read_exact_into(stream: &mut TcpStream, buf: &mut [u8]) -> io::Result<()> {
stream.read_exact(buf)
}
fn read_u16_len(stream: &mut TcpStream) -> io::Result<usize> {
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf)?;
Ok(u16::from_be_bytes(len_buf) as usize)
}
fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> io::Result<()> {
stream.write_all(&(packet.len() as u16).to_be_bytes())?;
stream.write_all(packet)?;
Ok(())
}
pub struct Channel {
stream: TcpStream,
transport: TransportState,
buf: Vec<u8>,
}
impl Channel {
pub fn new(stream: TcpStream, transport: TransportState) -> Self {
Channel {
stream,
transport,
buf: Vec::new(),
}
}
fn read_noise(&mut self) -> anyhow::Result<Vec<u8>> {
let len = read_u16_len(&mut self.stream)?;
let mut ciphertext = vec![0u8; len];
read_exact_into(&mut self.stream, &mut ciphertext)?;
let mut plaintext = vec![0u8; len];
let n = self.transport.read_message(&ciphertext, &mut plaintext)?;
plaintext.truncate(n);
Ok(plaintext)
}
fn write_noise(&mut self, payload: &[u8]) -> anyhow::Result<()> {
let mut packet = vec![0u8; payload.len() + 16];
let n = self.transport.write_message(payload, &mut packet)?;
packet.truncate(n);
write_u16_len(&mut self.stream, &packet)?;
Ok(())
}
/// Reads one application frame, blocking until a full frame is available.
pub fn read_frame(&mut self) -> anyhow::Result<(u8, Vec<u8>)> {
while self.buf.len() < 5 {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if length < 1 || length > MAX_FRAME {
return Err(ProtocolError::new(format!("frame length {length} out of range")).into());
}
while self.buf.len() < 4 + length {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
let frame: Vec<u8> = self.buf[4..4 + length].to_vec();
self.buf.drain(..4 + length);
Ok((frame[0], frame[1..].to_vec()))
}
pub fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> {
let mut frame = Vec::with_capacity(5 + body.len());
frame.extend_from_slice(&((1 + body.len()) as u32).to_be_bytes());
frame.push(op);
frame.extend_from_slice(body);
for chunk in frame.chunks(NOISE_PAYLOAD) {
self.write_noise(chunk)?;
}
Ok(())
}
}

88
src/crypto.rs Normal file
View file

@ -0,0 +1,88 @@
//! Encoding and cryptographic verification helpers.
use data_encoding::{Encoding, Specification};
use ed25519_dalek::{Signature, VerifyingKey};
use rand_core::OsRng;
use sha2::{Digest, Sha256};
use std::sync::LazyLock;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::proto::{LABEL_ID, ID_LEN};
static BASE32_LOWER_UNPADDED: LazyLock<Encoding> = LazyLock::new(|| {
let mut spec = Specification::new();
spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567");
spec.encoding().unwrap()
});
/// RFC 4648 base32, lowercase and unpadded.
pub fn b32(raw: &[u8]) -> String {
BASE32_LOWER_UNPADDED.encode(raw)
}
/// Derived from the envelope, so a sender cannot choose it.
pub fn message_id(envelope: &[u8]) -> [u8; ID_LEN] {
let mut hasher = Sha256::new();
hasher.update(LABEL_ID);
hasher.update(envelope);
let digest = hasher.finalize();
let mut out = [0u8; ID_LEN];
out.copy_from_slice(&digest[..ID_LEN]);
out
}
/// Generates the server's static X25519 keypair (transport identity, distinct
/// from any user's Ed25519 identity). Returns (private, public) raw bytes.
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {
let secret = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
(secret.to_bytes(), public.to_bytes())
}
/// Derives the X25519 public key for a raw static private key.
pub fn derive_public(private: &[u8; 32]) -> [u8; 32] {
let secret = StaticSecret::from(*private);
PublicKey::from(&secret).to_bytes()
}
/// Verifies an Ed25519 signature; malformed keys/signatures are simply not valid.
pub fn verify(pubkey: &[u8], signature: &[u8], message: &[u8]) -> bool {
let Ok(pubkey): Result<[u8; 32], _> = pubkey.try_into() else {
return false;
};
let Ok(signature): Result<[u8; 64], _> = signature.try_into() else {
return false;
};
let Ok(verifying_key) = VerifyingKey::from_bytes(&pubkey) else {
return false;
};
let signature = Signature::from_bytes(&signature);
verifying_key.verify_strict(message, &signature).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648_lowercase_unpadded() {
// "hello" -> base32 "NBSWY3DP" per RFC 4648, lowercased and unpadded.
assert_eq!(b32(b"hello"), "nbswy3dp");
}
#[test]
fn message_id_is_16_bytes_and_deterministic() {
let a = message_id(b"envelope-bytes");
let b = message_id(b"envelope-bytes");
let c = message_id(b"other-bytes");
assert_eq!(a.len(), ID_LEN);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn verify_rejects_garbage() {
assert!(!verify(&[0u8; 32], &[0u8; 64], b"msg"));
assert!(!verify(&[0u8; 5], &[0u8; 64], b"msg"));
}
}

123
src/main.rs Normal file
View file

@ -0,0 +1,123 @@
//! Smol Mail server.
mod channel;
mod crypto;
mod proto;
mod ratelimit;
mod server;
mod session;
mod store;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use clap::{Parser, Subcommand};
use crate::proto::DEFAULT_PORT;
#[derive(Parser)]
#[command(about = "Smol Mail server")]
struct Cli {
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Generate the server's static X25519 key
Keygen {
#[arg(long, default_value = "server.key")]
key: String,
#[arg(long)]
force: bool,
},
/// Run the mailbox server
Serve {
#[arg(long, default_value = "server.key")]
key: String,
#[arg(long, default_value = "mail.db")]
db: String,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = DEFAULT_PORT)]
port: u16,
#[arg(long = "max-envelope", default_value_t = 1 << 20)]
max_envelope: usize,
#[arg(long, default_value_t = 64 << 20)]
quota: i64,
#[arg(long = "retention-days", default_value_t = 30)]
retention_days: i64,
#[arg(long = "invite-token")]
invite_token: Option<String>,
#[arg(long = "rate-connections", default_value_t = 120)]
rate_connections: u32,
#[arg(long = "rate-sends", default_value_t = 60)]
rate_sends: u32,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
env_logger::Builder::new()
.filter_level(if cli.verbose {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
})
.format_timestamp_secs()
.init();
match cli.command {
Command::Keygen { key, force } => cmd_keygen(&key, force),
Command::Serve {
key,
db,
host,
port,
max_envelope,
quota,
retention_days,
invite_token,
rate_connections,
rate_sends,
} => server::run(server::ServeArgs {
key_path: key,
db_path: db,
host,
port,
max_envelope,
quota,
retention_days,
invite_token,
rate_connections,
rate_sends,
}),
}
}
fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
if std::path::Path::new(key_path).exists() && !force {
anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)");
}
let (private, public) = crypto::generate_static_key();
// Written 0600 before any bytes land, so the key is never briefly readable.
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(key_path)?;
file.write_all(&private)?;
println!("private key: {key_path}");
println!("public key: {}", crypto::b32(&public));
println!();
println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
Ok(())
}

166
src/proto.rs Normal file
View file

@ -0,0 +1,166 @@
//! Wire constants and body parsing.
use std::fmt;
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
pub const PROLOGUE: &[u8] = b"smolmail/1";
pub const LABEL_AUTH: &[u8] = b"smolmail/1 auth";
pub const LABEL_ID: &[u8] = b"smolmail/1 id";
pub const LABEL_ROTATE: &[u8] = b"smolmail/1 rotate";
pub const OP_AUTH: u8 = 0x00;
pub const OP_RESOLVE: u8 = 0x01;
pub const OP_SEND: u8 = 0x02;
pub const OP_FETCH: u8 = 0x03;
pub const OP_DELETE: u8 = 0x04;
pub const OP_REGISTER: u8 = 0x05;
pub const OK: u8 = 0;
pub const MALFORMED: u8 = 1;
pub const BAD_VERSION: u8 = 2;
pub const UNKNOWN_USER: u8 = 3;
pub const AUTH_REQUIRED: u8 = 4;
pub const AUTH_FAILED: u8 = 5;
pub const QUOTA_EXCEEDED: u8 = 6;
pub const TOO_LARGE: u8 = 7;
pub const RATE_LIMITED: u8 = 8;
pub const NOT_PERMITTED: u8 = 9;
pub const INTERNAL_ERROR: u8 = 10;
pub const ENVELOPE_MAGIC: &[u8; 4] = b"SMOL";
pub const ENVELOPE_VERSION: u8 = 1;
pub const ENVELOPE_HEADER: usize = 69; // magic 4 + version 1 + to 32 + epk 32
pub const ENVELOPE_MIN: usize = ENVELOPE_HEADER + 16; // + Poly1305 tag
pub const ID_LEN: usize = 16;
pub const KEY_LEN: usize = 32;
pub const CERT_LEN: usize = 136; // old_pub 32 + new_pub 32 + time 8 + signature 64
pub const MAX_CHAIN: usize = 16;
pub const DEFAULT_PORT: u16 = 1961;
pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling
pub const NOISE_MAX: usize = 65535; // Noise message ceiling
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME
pub const IDLE_TIMEOUT_SECS: u64 = 120;
pub const PURGE_INTERVAL_SECS: u64 = 60;
/// A peer sent something unparseable. Always answered with MALFORMED.
#[derive(Debug)]
pub struct ProtocolError(pub String);
impl fmt::Display for ProtocolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ProtocolError {}
impl ProtocolError {
pub fn new(msg: impl Into<String>) -> Self {
ProtocolError(msg.into())
}
}
/// Fail-closed reader over a frame body.
///
/// Every parse path errors rather than reading past the end, so a truncated
/// frame can never be mistaken for a short but valid one.
pub struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
pub fn take(&mut self, n: usize) -> Result<&'a [u8], ProtocolError> {
if self.pos + n > self.buf.len() {
return Err(ProtocolError::new(format!(
"short read: want {}, have {}",
n,
self.buf.len() - self.pos
)));
}
let out = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(out)
}
pub fn u8(&mut self) -> Result<u8, ProtocolError> {
Ok(self.take(1)?[0])
}
pub fn u16(&mut self) -> Result<u16, ProtocolError> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
pub fn rest(&mut self) -> &'a [u8] {
let out = &self.buf[self.pos..];
self.pos = self.buf.len();
out
}
pub fn done(&self) -> Result<(), ProtocolError> {
if self.pos != self.buf.len() {
return Err(ProtocolError::new(format!(
"{} trailing bytes",
self.buf.len() - self.pos
)));
}
Ok(())
}
}
/// 1-63 bytes of [a-z0-9._-], not starting or ending with a separator.
pub fn valid_username(name: &str) -> bool {
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > 63 {
return false;
}
if !bytes
.iter()
.all(|&c| c.is_ascii_digit() || c.is_ascii_lowercase() || matches!(c, b'.' | b'_' | b'-'))
{
return false;
}
let first = bytes[0];
let last = bytes[bytes.len() - 1];
!matches!(first, b'.' | b'_' | b'-') && !matches!(last, b'.' | b'_' | b'-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reader_bounds() {
let mut r = Reader::new(&[1, 2, 3]);
assert_eq!(r.u8().unwrap(), 1);
assert!(r.take(3).is_err());
assert_eq!(r.take(2).unwrap(), &[2, 3]);
assert!(r.done().is_ok());
}
#[test]
fn reader_trailing_bytes_rejected() {
let mut r = Reader::new(&[1, 2, 3]);
let _ = r.u8().unwrap();
assert!(r.done().is_err());
}
#[test]
fn username_validation() {
assert!(valid_username("alice"));
assert!(valid_username("a.b_c-9"));
assert!(!valid_username(""));
assert!(!valid_username(&"a".repeat(64)));
assert!(!valid_username(".alice"));
assert!(!valid_username("alice."));
assert!(!valid_username("Alice"));
assert!(!valid_username("al ice"));
}
}

82
src/ratelimit.rs Normal file
View file

@ -0,0 +1,82 @@
//! Fixed-window per-IP counter, the whole of the server's abuse control.
//!
//! A server cannot see senders, so quotas, size caps and this are all it has.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct Window {
start: Instant,
count: u32,
}
pub struct RateLimiter {
limit: u32,
window: Duration,
hits: Mutex<HashMap<String, Window>>,
}
impl RateLimiter {
pub fn new(limit: u32) -> Self {
RateLimiter {
limit,
window: Duration::from_secs(60),
hits: Mutex::new(HashMap::new()),
}
}
pub fn allow(&self, ip: &str) -> bool {
if self.limit == 0 {
return true;
}
let now = Instant::now();
let mut hits = self.hits.lock().unwrap();
let entry = hits.entry(ip.to_string()).or_insert(Window {
start: now,
count: 0,
});
if now.duration_since(entry.start) >= self.window {
entry.start = now;
entry.count = 0;
}
if entry.count >= self.limit {
return false;
}
entry.count += 1;
if hits.len() > 4096 {
let window = self.window;
hits.retain(|_, w| now.duration_since(w.start) < window);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_up_to_limit_then_blocks() {
let rl = RateLimiter::new(2);
assert!(rl.allow("1.2.3.4"));
assert!(rl.allow("1.2.3.4"));
assert!(!rl.allow("1.2.3.4"));
}
#[test]
fn zero_limit_means_unlimited() {
let rl = RateLimiter::new(0);
for _ in 0..100 {
assert!(rl.allow("1.2.3.4"));
}
}
#[test]
fn separate_ips_have_separate_windows() {
let rl = RateLimiter::new(1);
assert!(rl.allow("1.1.1.1"));
assert!(rl.allow("2.2.2.2"));
assert!(!rl.allow("1.1.1.1"));
}
}

163
src/server.rs Normal file
View file

@ -0,0 +1,163 @@
//! TCP accept loop, per-connection handling, and the background purge loop.
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
use crate::channel::{handshake, Channel};
use crate::crypto::{b32, derive_public};
use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
use crate::ratelimit::RateLimiter;
use crate::session::{ServerConfig, Session};
use crate::store::Store;
pub struct ServeArgs {
pub key_path: String,
pub db_path: String,
pub host: String,
pub port: u16,
pub max_envelope: usize,
pub quota: i64,
pub retention_days: i64,
pub invite_token: Option<String>,
pub rate_connections: u32,
pub rate_sends: u32,
}
pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let static_key =
std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
anyhow::ensure!(
static_key.len() == KEY_LEN,
"server key must be {KEY_LEN} raw bytes, got {}",
static_key.len()
);
let config = Arc::new(ServerConfig {
max_envelope: args.max_envelope,
quota: args.quota,
invite_token: args.invite_token.map(String::into_bytes),
conn_limiter: RateLimiter::new(args.rate_connections),
send_limiter: RateLimiter::new(args.rate_sends),
});
let retention_secs = args.retention_days * 86400;
let purge_db_path = args.db_path.clone();
std::thread::spawn(move || purge_loop(purge_db_path, retention_secs));
let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
log::info!("listening on {}:{}", args.host, args.port);
let key_array: [u8; KEY_LEN] = static_key.clone().try_into().unwrap();
let public = derive_public(&key_array);
log::info!("server public key: {}", b32(&public));
for incoming in listener.incoming() {
let stream = match incoming {
Ok(s) => s,
Err(e) => {
log::warn!("accept error: {e}");
continue;
}
};
let peer_ip = stream
.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|_| "unknown".to_string());
if !config.conn_limiter.allow(&peer_ip) {
log::warn!("rate limited {peer_ip}");
continue;
}
let config = Arc::clone(&config);
let static_key = static_key.clone();
let db_path = args.db_path.clone();
std::thread::spawn(move || {
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
log::info!("connection error from {peer_ip}: {e}");
}
});
}
Ok(())
}
fn handle_connection(
mut stream: TcpStream,
config: &ServerConfig,
static_key: &[u8],
db_path: &str,
peer_ip: &str,
) -> anyhow::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(IDLE_TIMEOUT_SECS)))?;
let (transport, handshake_hash) = match handshake(&mut stream, static_key) {
Ok(v) => v,
Err(e) => {
log::info!("handshake failed from {peer_ip}: {e}");
return Ok(());
}
};
let store = Store::open(db_path)?;
let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash);
let mut channel = Channel::new(stream, transport);
loop {
let (op, body) = match channel.read_frame() {
Ok(v) => v,
Err(e) => {
if is_eof_like(&e) {
return Ok(());
}
log::info!("bad frame from {peer_ip}: {e}");
let _ = channel.write_frame(0, &[MALFORMED]);
return Ok(());
}
};
let (status, payload) = session.dispatch(op, &body);
let mut response = Vec::with_capacity(1 + payload.len());
response.push(status);
response.extend_from_slice(&payload);
if channel.write_frame(op, &response).is_err() {
return Ok(());
}
}
}
fn is_eof_like(e: &anyhow::Error) -> bool {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
return matches!(
io_err.kind(),
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
);
}
false
}
fn purge_loop(db_path: String, retention_secs: i64) {
let store = match Store::open(&db_path) {
Ok(s) => s,
Err(e) => {
log::error!("purge thread failed to open store: {e}");
return;
}
};
loop {
std::thread::sleep(Duration::from_secs(PURGE_INTERVAL_SECS));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
match store.purge(now - retention_secs) {
Ok(0) => {}
Ok(n) => log::info!("expired {n} message(s)"),
Err(e) => log::error!("purge failed: {e}"),
}
}
}

264
src/session.rs Normal file
View file

@ -0,0 +1,264 @@
//! Per-connection dispatch and the six wire operations.
use crate::crypto::{message_id, verify};
use crate::proto::{
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN,
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH,
LABEL_ROTATE, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH, OP_DELETE, OP_FETCH,
OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOO_LARGE, UNKNOWN_USER,
};
use crate::ratelimit::RateLimiter;
use crate::store::Store;
pub struct ServerConfig {
pub max_envelope: usize,
pub quota: i64,
pub invite_token: Option<Vec<u8>>,
pub conn_limiter: RateLimiter,
pub send_limiter: RateLimiter,
}
/// A parse failure (-> MALFORMED) or a storage failure (-> INTERNAL_ERROR).
pub enum HandlerError {
Protocol(ProtocolError),
Store(rusqlite::Error),
}
impl From<ProtocolError> for HandlerError {
fn from(e: ProtocolError) -> Self {
HandlerError::Protocol(e)
}
}
impl From<rusqlite::Error> for HandlerError {
fn from(e: rusqlite::Error) -> Self {
HandlerError::Store(e)
}
}
type OpResult = Result<(u8, Vec<u8>), HandlerError>;
pub struct Session<'a> {
config: &'a ServerConfig,
store: Store,
peer_ip: String,
handshake_hash: Vec<u8>,
username: Option<String>,
}
impl<'a> Session<'a> {
pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec<u8>) -> Self {
Session {
config,
store,
peer_ip,
handshake_hash,
username: None,
}
}
/// Dispatches one frame, always producing a status to send back, never
/// panicking or propagating errors to the caller: a bad frame or a
/// storage error both become a response, and the caller decides
/// separately whether to keep the connection open.
pub fn dispatch(&mut self, op: u8, body: &[u8]) -> (u8, Vec<u8>) {
if matches!(op, OP_FETCH | OP_DELETE) && self.username.is_none() {
return (AUTH_REQUIRED, Vec::new());
}
let mut r = Reader::new(body);
let result = match op {
OP_AUTH => self.op_auth(&mut r),
OP_RESOLVE => self.op_resolve(&mut r),
OP_SEND => self.op_send(&mut r),
OP_FETCH => self.op_fetch(&mut r),
OP_DELETE => self.op_delete(&mut r),
OP_REGISTER => self.op_register(&mut r),
_ => return (MALFORMED, Vec::new()),
};
match result {
Ok(response) => response,
Err(HandlerError::Protocol(e)) => {
log::info!("bad body from {}: {}", self.peer_ip, e);
(MALFORMED, Vec::new())
}
Err(HandlerError::Store(e)) => {
log::error!("storage error from {}: {}", self.peer_ip, e);
(crate::proto::INTERNAL_ERROR, Vec::new())
}
}
}
fn read_str(r: &mut Reader) -> Result<String, ProtocolError> {
let len = r.u8()? as usize;
let bytes = r.take(len)?;
std::str::from_utf8(bytes)
.map(str::to_string)
.map_err(|_| ProtocolError::new("invalid utf-8"))
}
fn op_auth(&mut self, r: &mut Reader) -> OpResult {
let username = Self::read_str(r)?;
let identity = r.take(KEY_LEN)?.to_vec();
let signature = r.take(64)?.to_vec();
r.done()?;
let bound = self.store.identity_of(&username)?;
// A wrong username and a wrong signature are both AUTH_FAILED: telling
// them apart would turn this into an account-existence oracle.
if bound.as_deref() != Some(identity.as_slice()) {
return Ok((AUTH_FAILED, Vec::new()));
}
let mut msg = LABEL_AUTH.to_vec();
msg.extend_from_slice(&self.handshake_hash);
if !verify(&identity, &signature, &msg) {
return Ok((AUTH_FAILED, Vec::new()));
}
self.username = Some(username);
Ok((OK, Vec::new()))
}
fn op_resolve(&mut self, r: &mut Reader) -> OpResult {
let username = Self::read_str(r)?;
r.done()?;
let identity = match self.store.identity_of(&username)? {
Some(id) => id,
None => return Ok((UNKNOWN_USER, Vec::new())),
};
let chain = self.store.chain(&username)?;
let mut out = identity;
out.push(chain.len() as u8);
for cert in chain {
out.extend_from_slice(&cert);
}
Ok((OK, out))
}
fn op_send(&mut self, r: &mut Reader) -> OpResult {
let envelope = r.rest().to_vec();
if !self.config.send_limiter.allow(&self.peer_ip) {
return Ok((RATE_LIMITED, Vec::new()));
}
if envelope.len() > self.config.max_envelope {
return Ok((TOO_LARGE, Vec::new()));
}
if envelope.len() < ENVELOPE_MIN || &envelope[..4] != ENVELOPE_MAGIC {
return Ok((MALFORMED, Vec::new()));
}
if envelope[4] != ENVELOPE_VERSION {
return Ok((BAD_VERSION, Vec::new()));
}
let recipient = &envelope[5..37];
let username = match self.store.username_for_key(recipient)? {
Some(u) => u,
None => return Ok((UNKNOWN_USER, Vec::new())),
};
let keys = self.store.keys_of(&username)?;
let used = self.store.mailbox_bytes(&keys)?;
if used + envelope.len() as i64 > self.config.quota {
return Ok((QUOTA_EXCEEDED, Vec::new()));
}
// The ciphertext is never inspected; the server cannot read it.
let mid = message_id(&envelope);
self.store.store_message(&mid, recipient, &envelope)?;
Ok((OK, mid.to_vec()))
}
fn op_fetch(&mut self, r: &mut Reader) -> OpResult {
r.done()?;
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
let keys = self.store.keys_of(username)?;
let records = self.store.pending(&keys, FETCH_BUDGET)?;
let mut out = Vec::new();
out.extend_from_slice(&(records.len() as u16).to_be_bytes());
for (mid, received_at, envelope) in records {
out.extend_from_slice(&mid);
out.extend_from_slice(&received_at.to_be_bytes());
out.extend_from_slice(&(envelope.len() as u32).to_be_bytes());
out.extend_from_slice(&envelope);
}
Ok((OK, out))
}
fn op_delete(&mut self, r: &mut Reader) -> OpResult {
let count = r.u16()? as usize;
let mut ids = Vec::with_capacity(count);
for _ in 0..count {
ids.push(r.take(ID_LEN)?.to_vec());
}
r.done()?;
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
if ids.is_empty() {
return Ok((OK, 0u16.to_be_bytes().to_vec()));
}
// Scoped to the caller's own keys, so ids cannot be used to probe or
// delete another mailbox.
let keys = self.store.keys_of(username)?;
let removed = self.store.delete(&keys, &ids)?;
Ok((OK, (removed as u16).to_be_bytes().to_vec()))
}
fn op_register(&mut self, r: &mut Reader) -> OpResult {
let username = Self::read_str(r)?;
let identity = r.take(KEY_LEN)?.to_vec();
let token_len = r.u8()? as usize;
let token = r.take(token_len)?.to_vec();
let cert_len = r.u8()? as usize;
let cert = r.take(cert_len)?.to_vec();
r.done()?;
if !valid_username(&username) {
return Ok((MALFORMED, Vec::new()));
}
// identity is exactly KEY_LEN bytes by construction (Reader::take
// enforces it); no separate curve-point validity check is needed.
if let Some(expected) = &self.config.invite_token {
if &token != expected {
return Ok((NOT_PERMITTED, Vec::new()));
}
}
if cert.is_empty() {
if !self.store.register(&username, &identity)? {
return Ok((NOT_PERMITTED, Vec::new()));
}
return Ok((OK, Vec::new()));
}
if cert.len() != CERT_LEN {
return Ok((MALFORMED, Vec::new()));
}
let old_pub = &cert[..32];
let new_pub = &cert[32..64];
let when = &cert[64..72];
let signature = &cert[72..];
if new_pub != identity.as_slice() {
return Ok((MALFORMED, Vec::new()));
}
let bound = match self.store.identity_of(&username)? {
Some(b) => b,
None => return Ok((UNKNOWN_USER, Vec::new())),
};
// Only the currently bound key may hand the username on.
if bound != old_pub {
return Ok((NOT_PERMITTED, Vec::new()));
}
let mut msg = LABEL_ROTATE.to_vec();
msg.extend_from_slice(old_pub);
msg.extend_from_slice(new_pub);
msg.extend_from_slice(when);
if !verify(old_pub, signature, &msg) {
return Ok((AUTH_FAILED, Vec::new()));
}
let chain = self.store.chain(&username)?;
if chain.len() >= MAX_CHAIN {
return Ok((NOT_PERMITTED, Vec::new()));
}
if !self.store.rotate(&username, new_pub, &cert, chain.len())? {
return Ok((NOT_PERMITTED, Vec::new()));
}
Ok((OK, Vec::new()))
}
}

243
src/store.rs Normal file
View file

@ -0,0 +1,243 @@
//! SQLite-backed mailbox storage.
//!
//! Each connection thread opens its own `Store` (own `rusqlite::Connection`),
//! since SQLite connections aren't meant to be shared across threads.
use std::time::Duration;
use rusqlite::{params_from_iter, Connection, OptionalExtension};
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
identity BLOB NOT NULL
);
-- Every key ever bound to a username, so a superseded key stays addressable
-- across a rotation.
CREATE TABLE IF NOT EXISTS keys (
identity BLOB PRIMARY KEY,
username TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rotations (
username TEXT NOT NULL,
seq INTEGER NOT NULL,
cert BLOB NOT NULL,
PRIMARY KEY (username, seq)
);
CREATE TABLE IF NOT EXISTS messages (
id BLOB PRIMARY KEY,
recipient BLOB NOT NULL,
received_at INTEGER NOT NULL,
envelope BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS messages_by_recipient
ON messages (recipient, received_at);
";
pub struct Store {
conn: Connection,
}
fn placeholders(n: usize) -> String {
vec!["?"; n].join(",")
}
impl Store {
pub fn open(path: &str) -> rusqlite::Result<Self> {
let conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(10))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(SCHEMA)?;
Ok(Store { conn })
}
pub fn identity_of(&self, username: &str) -> rusqlite::Result<Option<Vec<u8>>> {
self.conn
.query_row(
"SELECT identity FROM users WHERE username = ?1",
[username],
|row| row.get(0),
)
.optional()
}
pub fn chain(&self, username: &str) -> rusqlite::Result<Vec<Vec<u8>>> {
let mut stmt = self
.conn
.prepare("SELECT cert FROM rotations WHERE username = ?1 ORDER BY seq")?;
let rows = stmt.query_map([username], |row| row.get(0))?;
rows.collect()
}
pub fn keys_of(&self, username: &str) -> rusqlite::Result<Vec<Vec<u8>>> {
let mut stmt = self
.conn
.prepare("SELECT identity FROM keys WHERE username = ?1")?;
let rows = stmt.query_map([username], |row| row.get(0))?;
rows.collect()
}
pub fn username_for_key(&self, identity: &[u8]) -> rusqlite::Result<Option<String>> {
self.conn
.query_row(
"SELECT username FROM keys WHERE identity = ?1",
[identity],
|row| row.get(0),
)
.optional()
}
/// False on conflict: username taken, or this key is already bound elsewhere.
pub fn register(&self, username: &str, identity: &[u8]) -> rusqlite::Result<bool> {
let tx = self.conn.unchecked_transaction()?;
let result = (|| -> rusqlite::Result<()> {
tx.execute(
"INSERT INTO users (username, identity) VALUES (?1, ?2)",
(username, identity),
)?;
tx.execute(
"INSERT INTO keys (identity, username) VALUES (?1, ?2)",
(identity, username),
)?;
Ok(())
})();
match result {
Ok(()) => {
tx.commit()?;
Ok(true)
}
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
pub fn rotate(
&self,
username: &str,
new_key: &[u8],
cert: &[u8],
seq: usize,
) -> rusqlite::Result<bool> {
let tx = self.conn.unchecked_transaction()?;
let result = (|| -> rusqlite::Result<()> {
tx.execute(
"UPDATE users SET identity = ?1 WHERE username = ?2",
(new_key, username),
)?;
tx.execute(
"INSERT INTO keys (identity, username) VALUES (?1, ?2)",
(new_key, username),
)?;
tx.execute(
"INSERT INTO rotations (username, seq, cert) VALUES (?1, ?2, ?3)",
(username, seq as i64, cert),
)?;
Ok(())
})();
match result {
Ok(()) => {
tx.commit()?;
Ok(true)
}
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
pub fn mailbox_bytes(&self, keys: &[Vec<u8>]) -> rusqlite::Result<i64> {
if keys.is_empty() {
return Ok(0);
}
let sql = format!(
"SELECT COALESCE(SUM(LENGTH(envelope)), 0) FROM messages WHERE recipient IN ({})",
placeholders(keys.len())
);
self.conn
.query_row(&sql, params_from_iter(keys.iter()), |row| row.get(0))
}
pub fn store_message(
&self,
mid: &[u8],
recipient: &[u8],
envelope: &[u8],
) -> rusqlite::Result<()> {
self.conn.execute(
"INSERT OR IGNORE INTO messages (id, recipient, received_at, envelope) \
VALUES (?1, ?2, ?3, ?4)",
(
mid,
recipient,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
envelope,
),
)?;
Ok(())
}
/// Always returns at least one message, even if it alone exceeds `budget`,
/// so an oversized envelope cannot wedge a mailbox shut.
pub fn pending(
&self,
keys: &[Vec<u8>],
budget: usize,
) -> rusqlite::Result<Vec<(Vec<u8>, i64, Vec<u8>)>> {
if keys.is_empty() {
return Ok(Vec::new());
}
let sql = format!(
"SELECT id, received_at, envelope FROM messages \
WHERE recipient IN ({}) ORDER BY received_at, id",
placeholders(keys.len())
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(keys.iter()), |row| {
Ok((
row.get::<_, Vec<u8>>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Vec<u8>>(2)?,
))
})?;
let mut out = Vec::new();
let mut used = 0usize;
for row in rows {
let (mid, received_at, envelope) = row?;
if !out.is_empty() && used + envelope.len() > budget {
break;
}
used += envelope.len();
out.push((mid, received_at, envelope));
}
Ok(out)
}
pub fn delete(&self, keys: &[Vec<u8>], ids: &[Vec<u8>]) -> rusqlite::Result<usize> {
if keys.is_empty() || ids.is_empty() {
return Ok(0);
}
let sql = format!(
"DELETE FROM messages WHERE id IN ({}) AND recipient IN ({})",
placeholders(ids.len()),
placeholders(keys.len())
);
let params: Vec<&Vec<u8>> = ids.iter().chain(keys.iter()).collect();
self.conn.execute(&sql, params_from_iter(params))
}
pub fn purge(&self, older_than: i64) -> rusqlite::Result<usize> {
self.conn
.execute("DELETE FROM messages WHERE received_at < ?1", [older_than])
}
}