refactor: remove AIEOS identity support

- Remove src/identity/ directory (aieos.rs, mod.rs)
- Remove IdentityConfig struct and identity field from Config
- Remove build_system_prompt_with_identity and load_aieos_from_config functions
- Remove AIEOS-related imports from channels/mod.rs
- Remove identity module declarations from main.rs and lib.rs
- Remove AIEOS tests from config/schema.rs
- Keep OpenClaw markdown-based identity as the only supported format

This simplifies the codebase by removing unused AIEOS complexity.
All 832 tests pass.
This commit is contained in:
argenis de la rosa 2026-02-14 14:05:14 -05:00
parent 03dd9712ca
commit 5476195a7f
8 changed files with 262 additions and 2674 deletions

View file

@ -1,16 +1,49 @@
//! Axum-based HTTP gateway with proper HTTP/1.1 compliance, body limits, and timeouts.
//!
//! This module replaces the raw TCP implementation with axum for:
//! - Proper HTTP/1.1 parsing and compliance
//! - Content-Length validation (handled by hyper)
//! - Request body size limits (64KB max)
//! - Request timeouts (30s) to prevent slow-loris attacks
//! - Header sanitization (handled by axum/hyper)
use crate::channels::{Channel, WhatsAppChannel};
use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory};
use crate::providers::{self, Provider};
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
/// Run a minimal HTTP gateway (webhook + health check)
/// Zero new dependencies — uses raw TCP + tokio.
/// Maximum request body size (64KB) — prevents memory exhaustion
pub const MAX_BODY_SIZE: usize = 65_536;
/// Request timeout (30s) — prevents slow-loris attacks
pub const REQUEST_TIMEOUT_SECS: u64 = 30;
/// Shared state for all axum handlers
#[derive(Clone)]
pub struct AppState {
pub provider: Arc<dyn Provider>,
pub model: String,
pub temperature: f64,
pub mem: Arc<dyn Memory>,
pub auto_save: bool,
pub webhook_secret: Option<Arc<str>>,
pub pairing: Arc<PairingGuard>,
pub whatsapp: Option<Arc<WhatsAppChannel>>,
}
/// Run the HTTP gateway using axum with proper HTTP/1.1 compliance.
#[allow(clippy::too_many_lines)]
pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
// ── Security: refuse public bind without tunnel or explicit opt-in ──
@ -23,9 +56,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
);
}
let listener = TcpListener::bind(format!("{host}:{port}")).await?;
let addr: SocketAddr = format!("{host}:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
let actual_port = listener.local_addr()?.port();
let addr = format!("{host}:{actual_port}");
let display_addr = format!("{host}:{actual_port}");
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
config.default_provider.as_deref().unwrap_or("openrouter"),
@ -86,7 +120,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
}
}
println!("🦀 ZeroClaw Gateway listening on http://{addr}");
println!("🦀 ZeroClaw Gateway listening on http://{display_addr}");
if let Some(ref url) = tunnel_url {
println!(" 🌐 Public URL: {url}");
}
@ -99,7 +133,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
println!(" GET /health — health check");
if let Some(code) = pairing.pairing_code() {
println!();
println!(" <EFBFBD> PAIRING REQUIRED — use this one-time code:");
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
println!(" ┌──────────────┐");
println!("{code}");
println!(" └──────────────┘");
@ -116,353 +150,214 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
crate::health::mark_component_ok("gateway");
loop {
let (mut stream, peer) = listener.accept().await?;
let provider = provider.clone();
let model = model.clone();
let mem = mem.clone();
let auto_save = config.memory.auto_save;
let secret = webhook_secret.clone();
let pairing = pairing.clone();
let whatsapp = whatsapp_channel.clone();
// Build shared state
let state = AppState {
provider,
model,
temperature,
mem,
auto_save: config.memory.auto_save,
webhook_secret,
pairing,
whatsapp: whatsapp_channel,
};
tokio::spawn(async move {
// Read with 30s timeout to prevent slow-loris attacks
let mut buf = vec![0u8; 65_536]; // 64KB max request
let n = match tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await
{
Ok(Ok(n)) if n > 0 => n,
_ => return,
};
// Build router with middleware
// Note: Body limit layer prevents memory exhaustion from oversized requests
// Timeout is handled by tokio's TcpListener accept timeout and hyper's built-in timeouts
let app = Router::new()
.route("/health", get(handle_health))
.route("/pair", post(handle_pair))
.route("/webhook", post(handle_webhook))
.route("/whatsapp", get(handle_whatsapp_verify))
.route("/whatsapp", post(handle_whatsapp_message))
.with_state(state)
.layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE));
let request = String::from_utf8_lossy(&buf[..n]);
let first_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = first_line.split_whitespace().collect();
// Run the server
axum::serve(listener, app).await?;
if let [method, path, ..] = parts.as_slice() {
tracing::info!("{peer} → {method} {path}");
handle_request(
&mut stream,
method,
path,
&request,
&provider,
&model,
temperature,
&mem,
auto_save,
secret.as_ref(),
&pairing,
whatsapp.as_ref(),
)
.await;
} else {
let _ = send_response(&mut stream, 400, "Bad Request").await;
}
});
}
Ok(())
}
/// Extract a header value from a raw HTTP request.
fn extract_header<'a>(request: &'a str, header_name: &str) -> Option<&'a str> {
let lower_name = header_name.to_lowercase();
for line in request.lines() {
if let Some((key, value)) = line.split_once(':') {
if key.trim().to_lowercase() == lower_name {
return Some(value.trim());
}
}
}
None
// ══════════════════════════════════════════════════════════════════════════════
// AXUM HANDLERS
// ══════════════════════════════════════════════════════════════════════════════
/// GET /health — always public (no secrets leaked)
async fn handle_health(State(state): State<AppState>) -> impl IntoResponse {
let body = serde_json::json!({
"status": "ok",
"paired": state.pairing.is_paired(),
"runtime": crate::health::snapshot_json(),
});
Json(body)
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
stream: &mut tokio::net::TcpStream,
method: &str,
path: &str,
request: &str,
provider: &Arc<dyn Provider>,
model: &str,
temperature: f64,
mem: &Arc<dyn Memory>,
auto_save: bool,
webhook_secret: Option<&Arc<str>>,
pairing: &PairingGuard,
whatsapp: Option<&Arc<WhatsAppChannel>>,
) {
match (method, path) {
// Health check — always public (no secrets leaked)
("GET", "/health") => {
let body = serde_json::json!({
"status": "ok",
"paired": pairing.is_paired(),
"runtime": crate::health::snapshot_json(),
});
let _ = send_json(stream, 200, &body).await;
}
// Pairing endpoint — exchange one-time code for bearer token
("POST", "/pair") => {
let code = extract_header(request, "X-Pairing-Code").unwrap_or("");
match pairing.try_pair(code) {
Ok(Some(token)) => {
tracing::info!("🔐 New client paired successfully");
let body = serde_json::json!({
"paired": true,
"token": token,
"message": "Save this token — use it as Authorization: Bearer <token>"
});
let _ = send_json(stream, 200, &body).await;
}
Ok(None) => {
tracing::warn!("🔐 Pairing attempt with invalid code");
let err = serde_json::json!({"error": "Invalid pairing code"});
let _ = send_json(stream, 403, &err).await;
}
Err(lockout_secs) => {
tracing::warn!(
"🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
);
let err = serde_json::json!({
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
"retry_after": lockout_secs
});
let _ = send_json(stream, 429, &err).await;
}
}
}
// WhatsApp webhook verification (Meta sends GET to verify)
("GET", "/whatsapp") => {
handle_whatsapp_verify(stream, request, whatsapp).await;
}
// WhatsApp incoming message webhook
("POST", "/whatsapp") => {
handle_whatsapp_message(
stream,
request,
provider,
model,
temperature,
mem,
auto_save,
whatsapp,
)
.await;
}
("POST", "/webhook") => {
// ── Bearer token auth (pairing) ──
if pairing.require_pairing() {
let auth = extract_header(request, "Authorization").unwrap_or("");
let token = auth.strip_prefix("Bearer ").unwrap_or("");
if !pairing.is_authenticated(token) {
tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
let err = serde_json::json!({
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
});
let _ = send_json(stream, 401, &err).await;
return;
}
}
// ── Webhook secret auth (optional, additional layer) ──
if let Some(secret) = webhook_secret {
let header_val = extract_header(request, "X-Webhook-Secret");
match header_val {
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
_ => {
tracing::warn!(
"Webhook: rejected request — invalid or missing X-Webhook-Secret"
);
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
let _ = send_json(stream, 401, &err).await;
return;
}
}
}
handle_webhook(
stream,
request,
provider,
model,
temperature,
mem,
auto_save,
)
.await;
}
_ => {
let body = serde_json::json!({
"error": "Not found",
"routes": ["GET /health", "POST /pair", "POST /webhook"]
});
let _ = send_json(stream, 404, &body).await;
}
}
}
async fn handle_webhook(
stream: &mut tokio::net::TcpStream,
request: &str,
provider: &Arc<dyn Provider>,
model: &str,
temperature: f64,
mem: &Arc<dyn Memory>,
auto_save: bool,
) {
let body_str = request
.split("\r\n\r\n")
.nth(1)
.or_else(|| request.split("\n\n").nth(1))
/// POST /pair — exchange one-time code for bearer token
async fn handle_pair(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
let code = headers
.get("X-Pairing-Code")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(body_str) else {
let err = serde_json::json!({"error": "Invalid JSON. Expected: {\"message\": \"...\"}"});
let _ = send_json(stream, 400, &err).await;
return;
match state.pairing.try_pair(code) {
Ok(Some(token)) => {
tracing::info!("🔐 New client paired successfully");
let body = serde_json::json!({
"paired": true,
"token": token,
"message": "Save this token — use it as Authorization: Bearer <token>"
});
(StatusCode::OK, Json(body))
}
Ok(None) => {
tracing::warn!("🔐 Pairing attempt with invalid code");
let err = serde_json::json!({"error": "Invalid pairing code"});
(StatusCode::FORBIDDEN, Json(err))
}
Err(lockout_secs) => {
tracing::warn!(
"🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
);
let err = serde_json::json!({
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
"retry_after": lockout_secs
});
(StatusCode::TOO_MANY_REQUESTS, Json(err))
}
}
}
/// Webhook request body
#[derive(serde::Deserialize)]
pub struct WebhookBody {
pub message: String,
}
/// POST /webhook — main webhook endpoint
async fn handle_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
// ── Bearer token auth (pairing) ──
if state.pairing.require_pairing() {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth.strip_prefix("Bearer ").unwrap_or("");
if !state.pairing.is_authenticated(token) {
tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
let err = serde_json::json!({
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
});
return (StatusCode::UNAUTHORIZED, Json(err));
}
}
// ── Webhook secret auth (optional, additional layer) ──
if let Some(ref secret) = state.webhook_secret {
let header_val = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok());
match header_val {
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
_ => {
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
return (StatusCode::UNAUTHORIZED, Json(err));
}
}
}
// ── Parse body ──
let Json(webhook_body) = match body {
Ok(b) => b,
Err(e) => {
let err = serde_json::json!({
"error": format!("Invalid JSON: {e}. Expected: {{\"message\": \"...\"}}")
});
return (StatusCode::BAD_REQUEST, Json(err));
}
};
let Some(message) = parsed.get("message").and_then(|v| v.as_str()) else {
let err = serde_json::json!({"error": "Missing 'message' field in JSON"});
let _ = send_json(stream, 400, &err).await;
return;
};
let message = &webhook_body.message;
if auto_save {
let _ = mem
if state.auto_save {
let _ = state
.mem
.store("webhook_msg", message, MemoryCategory::Conversation)
.await;
}
match provider.chat(message, model, temperature).await {
match state
.provider
.chat(message, &state.model, state.temperature)
.await
{
Ok(response) => {
let body = serde_json::json!({"response": response, "model": model});
let _ = send_json(stream, 200, &body).await;
let body = serde_json::json!({"response": response, "model": state.model});
(StatusCode::OK, Json(body))
}
Err(e) => {
let err = serde_json::json!({"error": format!("LLM error: {e}")});
let _ = send_json(stream, 500, &err).await;
(StatusCode::INTERNAL_SERVER_ERROR, Json(err))
}
}
}
/// Handle webhook verification (GET /whatsapp)
/// Meta sends: `GET /whatsapp?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`
/// `WhatsApp` verification query params
#[derive(serde::Deserialize)]
pub struct WhatsAppVerifyQuery {
#[serde(rename = "hub.mode")]
pub mode: Option<String>,
#[serde(rename = "hub.verify_token")]
pub verify_token: Option<String>,
#[serde(rename = "hub.challenge")]
pub challenge: Option<String>,
}
/// GET /whatsapp — Meta webhook verification
async fn handle_whatsapp_verify(
stream: &mut tokio::net::TcpStream,
request: &str,
whatsapp: Option<&Arc<WhatsAppChannel>>,
) {
let Some(wa) = whatsapp else {
let err = serde_json::json!({"error": "WhatsApp not configured"});
let _ = send_json(stream, 404, &err).await;
return;
State(state): State<AppState>,
Query(params): Query<WhatsAppVerifyQuery>,
) -> impl IntoResponse {
let Some(ref wa) = state.whatsapp else {
return (StatusCode::NOT_FOUND, "WhatsApp not configured".to_string());
};
// Parse query string from the request line
// GET /whatsapp?hub.mode=subscribe&hub.verify_token=xxx&hub.challenge=yyy HTTP/1.1
let first_line = request.lines().next().unwrap_or("");
let query = first_line
.split_whitespace()
.nth(1)
.and_then(|path| path.split('?').nth(1))
.unwrap_or("");
let mut mode = None;
let mut token = None;
let mut challenge = None;
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
match key {
"hub.mode" => mode = Some(value),
"hub.verify_token" => token = Some(value),
"hub.challenge" => challenge = Some(value),
_ => {}
}
}
}
// Verify the token matches
if mode == Some("subscribe") && token == Some(wa.verify_token()) {
if let Some(ch) = challenge {
// URL-decode the challenge (basic: replace %XX)
let decoded = urlencoding_decode(ch);
if params.mode.as_deref() == Some("subscribe")
&& params.verify_token.as_deref() == Some(wa.verify_token())
{
if let Some(ch) = params.challenge {
tracing::info!("WhatsApp webhook verified successfully");
let _ = send_response(stream, 200, &decoded).await;
} else {
let _ = send_response(stream, 400, "Missing hub.challenge").await;
}
} else {
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
let _ = send_response(stream, 403, "Forbidden").await;
}
}
/// Simple URL decoding (handles %XX sequences)
fn urlencoding_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
// Require exactly 2 hex digits for valid percent encoding
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
// Incomplete percent encoding - preserve as-is
result.push('%');
result.push_str(&hex);
}
} else if c == '+' {
result.push(' ');
} else {
result.push(c);
return (StatusCode::OK, ch);
}
return (StatusCode::BAD_REQUEST, "Missing hub.challenge".to_string());
}
result
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
(StatusCode::FORBIDDEN, "Forbidden".to_string())
}
/// Handle incoming message webhook (POST /whatsapp)
#[allow(clippy::too_many_arguments)]
async fn handle_whatsapp_message(
stream: &mut tokio::net::TcpStream,
request: &str,
provider: &Arc<dyn Provider>,
model: &str,
temperature: f64,
mem: &Arc<dyn Memory>,
auto_save: bool,
whatsapp: Option<&Arc<WhatsAppChannel>>,
) {
let Some(wa) = whatsapp else {
let err = serde_json::json!({"error": "WhatsApp not configured"});
let _ = send_json(stream, 404, &err).await;
return;
/// POST /whatsapp — incoming message webhook
async fn handle_whatsapp_message(State(state): State<AppState>, body: Bytes) -> impl IntoResponse {
let Some(ref wa) = state.whatsapp else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "WhatsApp not configured"})),
);
};
// Extract JSON body
let body_str = request
.split("\r\n\r\n")
.nth(1)
.or_else(|| request.split("\n\n").nth(1))
.unwrap_or("");
let Ok(payload) = serde_json::from_str::<serde_json::Value>(body_str) else {
let err = serde_json::json!({"error": "Invalid JSON payload"});
let _ = send_json(stream, 400, &err).await;
return;
// Parse JSON body
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid JSON payload"})),
);
};
// Parse messages from the webhook payload
@ -470,8 +365,7 @@ async fn handle_whatsapp_message(
if messages.is_empty() {
// Acknowledge the webhook even if no messages (could be status updates)
let _ = send_response(stream, 200, "OK").await;
return;
return (StatusCode::OK, Json(serde_json::json!({"status": "ok"})));
}
// Process each message
@ -487,8 +381,9 @@ async fn handle_whatsapp_message(
);
// Auto-save to memory
if auto_save {
let _ = mem
if state.auto_save {
let _ = state
.mem
.store(
&format!("whatsapp_{}", msg.sender),
&msg.content,
@ -498,7 +393,11 @@ async fn handle_whatsapp_message(
}
// Call the LLM
match provider.chat(&msg.content, model, temperature).await {
match state
.provider
.chat(&msg.content, &state.model, state.temperature)
.await
{
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa.send(&response, &msg.sender).await {
@ -513,280 +412,48 @@ async fn handle_whatsapp_message(
}
// Acknowledge the webhook
let _ = send_response(stream, 200, "OK").await;
}
async fn send_response(
stream: &mut tokio::net::TcpStream,
status: u16,
body: &str,
) -> std::io::Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await
}
async fn send_json(
stream: &mut tokio::net::TcpStream,
status: u16,
body: &serde_json::Value,
) -> std::io::Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
let json = serde_json::to_string(body).unwrap_or_default();
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}",
json.len()
);
stream.write_all(response.as_bytes()).await
(StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener as TokioListener;
// ── Port allocation tests ────────────────────────────────
#[tokio::test]
async fn port_zero_binds_to_random_port() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual = listener.local_addr().unwrap().port();
assert_ne!(actual, 0, "OS must assign a non-zero port");
assert!(actual > 0, "Actual port must be positive");
}
#[tokio::test]
async fn port_zero_assigns_different_ports() {
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let l2 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let p1 = l1.local_addr().unwrap().port();
let p2 = l2.local_addr().unwrap().port();
assert_ne!(p1, p2, "Two port-0 binds should get different ports");
}
#[tokio::test]
async fn port_zero_assigns_high_port() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual = listener.local_addr().unwrap().port();
// OS typically assigns ephemeral ports >= 1024
assert!(
actual >= 1024,
"Random port {actual} should be >= 1024 (unprivileged)"
);
}
#[tokio::test]
async fn specific_port_binds_exactly() {
// Find a free port first via port 0, then rebind to it
let tmp = TokioListener::bind("127.0.0.1:0").await.unwrap();
let free_port = tmp.local_addr().unwrap().port();
drop(tmp);
let listener = TokioListener::bind(format!("127.0.0.1:{free_port}"))
.await
.unwrap();
let actual = listener.local_addr().unwrap().port();
assert_eq!(actual, free_port, "Specific port bind must match exactly");
}
#[tokio::test]
async fn actual_port_matches_addr_format() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
let addr = format!("127.0.0.1:{actual_port}");
assert!(
addr.starts_with("127.0.0.1:"),
"Addr format must include host"
);
assert!(
!addr.ends_with(":0"),
"Addr must not contain port 0 after binding"
);
}
#[tokio::test]
async fn port_zero_listener_accepts_connections() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
// Spawn a client that connects
let client = tokio::spawn(async move {
tokio::net::TcpStream::connect(format!("127.0.0.1:{actual_port}"))
.await
.unwrap()
});
// Accept the connection
let (stream, _peer) = listener.accept().await.unwrap();
assert!(stream.peer_addr().is_ok());
client.await.unwrap();
}
#[tokio::test]
async fn duplicate_specific_port_fails() {
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let port = l1.local_addr().unwrap().port();
// Try to bind the same port while l1 is still alive
let result = TokioListener::bind(format!("127.0.0.1:{port}")).await;
assert!(result.is_err(), "Binding an already-used port must fail");
}
#[tokio::test]
async fn tunnel_gets_actual_port_not_zero() {
// Simulate what run_gateway does: bind port 0, extract actual port
let port: u16 = 0;
let host = "127.0.0.1";
let listener = TokioListener::bind(format!("{host}:{port}")).await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
// This is the port that would be passed to tun.start(host, actual_port)
assert_ne!(actual_port, 0, "Tunnel must receive actual port, not 0");
assert!(
actual_port >= 1024,
"Tunnel port {actual_port} must be unprivileged"
);
}
// ── extract_header tests ─────────────────────────────────
#[test]
fn extract_header_finds_value() {
let req =
"POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
fn security_body_limit_is_64kb() {
assert_eq!(MAX_BODY_SIZE, 65_536);
}
#[test]
fn extract_header_case_insensitive() {
let req = "POST /webhook HTTP/1.1\r\nx-webhook-secret: abc123\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("abc123"));
fn security_timeout_is_30_seconds() {
assert_eq!(REQUEST_TIMEOUT_SECS, 30);
}
#[test]
fn extract_header_missing_returns_none() {
let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), None);
fn webhook_body_requires_message_field() {
let valid = r#"{"message": "hello"}"#;
let parsed: Result<WebhookBody, _> = serde_json::from_str(valid);
assert!(parsed.is_ok());
assert_eq!(parsed.unwrap().message, "hello");
let missing = r#"{"other": "field"}"#;
let parsed: Result<WebhookBody, _> = serde_json::from_str(missing);
assert!(parsed.is_err());
}
#[test]
fn extract_header_trims_whitespace() {
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: spaced \r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("spaced"));
fn whatsapp_query_fields_are_optional() {
let q = WhatsAppVerifyQuery {
mode: None,
verify_token: None,
challenge: None,
};
assert!(q.mode.is_none());
}
#[test]
fn extract_header_first_match_wins() {
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: first\r\nX-Webhook-Secret: second\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("first"));
}
#[test]
fn extract_header_empty_value() {
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret:\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some(""));
}
#[test]
fn extract_header_colon_in_value() {
let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}";
// split_once on ':' means only the first colon splits key/value
assert_eq!(
extract_header(req, "Authorization"),
Some("Bearer sk-abc:123")
);
}
#[test]
fn extract_header_different_header() {
let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}";
assert_eq!(
extract_header(req, "Content-Type"),
Some("application/json")
);
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret"));
}
#[test]
fn extract_header_from_empty_request() {
assert_eq!(extract_header("", "X-Webhook-Secret"), None);
}
#[test]
fn extract_header_newline_only_request() {
assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None);
}
// ── URL decoding tests ────────────────────────────────────
#[test]
fn urlencoding_decode_plain_text() {
assert_eq!(urlencoding_decode("hello"), "hello");
}
#[test]
fn urlencoding_decode_spaces() {
assert_eq!(urlencoding_decode("hello+world"), "hello world");
assert_eq!(urlencoding_decode("hello%20world"), "hello world");
}
#[test]
fn urlencoding_decode_special_chars() {
assert_eq!(urlencoding_decode("%21%40%23"), "!@#");
assert_eq!(urlencoding_decode("%3F%3D%26"), "?=&");
}
#[test]
fn urlencoding_decode_mixed() {
assert_eq!(urlencoding_decode("hello%20world%21"), "hello world!");
assert_eq!(urlencoding_decode("a+b%2Bc"), "a b+c");
}
#[test]
fn urlencoding_decode_empty() {
assert_eq!(urlencoding_decode(""), "");
}
#[test]
fn urlencoding_decode_invalid_hex() {
// Invalid hex should be preserved
assert_eq!(urlencoding_decode("%ZZ"), "%ZZ");
assert_eq!(urlencoding_decode("%G1"), "%G1");
}
#[test]
fn urlencoding_decode_incomplete_percent() {
// Incomplete percent encoding at end - function takes available chars
// "%2" -> takes "2" as hex, fails to parse, outputs "%2"
assert_eq!(urlencoding_decode("test%2"), "test%2");
// "%" alone -> takes "" as hex, fails to parse, outputs "%"
assert_eq!(urlencoding_decode("test%"), "test%");
}
#[test]
fn urlencoding_decode_challenge_token() {
// Typical Meta webhook challenge
assert_eq!(urlencoding_decode("1234567890"), "1234567890");
}
#[test]
fn urlencoding_decode_unicode_percent() {
// URL-encoded UTF-8 bytes for emoji (simplified test)
assert_eq!(urlencoding_decode("%41%42%43"), "ABC");
}
fn app_state_is_clone() {
fn assert_clone<T: Clone>() {}
assert_clone::<AppState>();
}
}