use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecoyProfile {
RestApi,
HealthCheck,
Metrics,
}
pub struct DecoyEndpoint {
pub path: String,
pub method: String,
pub profile: DecoyProfile,
pub extra_headers: HashMap<String, String>,
}
impl DecoyEndpoint {
pub fn new(path: impl Into<String>, method: impl Into<String>, profile: DecoyProfile) -> Self {
Self {
path: path.into(),
method: method.into(),
profile,
extra_headers: HashMap::new(),
}
}
pub fn rest_api() -> Self {
Self::new("/api/v1/status", "GET", DecoyProfile::RestApi)
}
pub fn health_check() -> Self {
Self::new("/healthz", "GET", DecoyProfile::HealthCheck)
}
pub fn metrics() -> Self {
Self::new("/metrics", "GET", DecoyProfile::Metrics)
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.insert(key.into(), value.into());
self
}
pub fn matches(&self, method: &str, path: &str) -> bool {
self.method.eq_ignore_ascii_case(method) && self.path == path
}
pub fn decoy_response(&self) -> Vec<u8> {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match self.profile {
DecoyProfile::RestApi => format!(
r#"{{"status":"ok","version":"2.4.1","timestamp":{ts},"request_id":"{rid}"}}"#,
rid = pseudo_request_id(ts),
)
.into_bytes(),
DecoyProfile::HealthCheck => format!(
r#"{{"healthy":true,"uptime_s":{uptime},"checks":{{"db":"pass","cache":"pass"}}}}"#,
uptime = ts % 864_000, )
.into_bytes(),
DecoyProfile::Metrics => format!(
"# HELP http_requests_total Total HTTP requests.\n\
# TYPE http_requests_total counter\n\
http_requests_total{{method=\"GET\"}} {get}\n\
http_requests_total{{method=\"POST\"}} {post}\n\
# HELP process_uptime_seconds Process uptime.\n\
# TYPE process_uptime_seconds gauge\n\
process_uptime_seconds {uptime}\n",
get = ts % 100_000,
post = (ts / 3) % 50_000,
uptime = ts % 864_000,
)
.into_bytes(),
}
}
pub fn content_type(&self) -> &'static str {
match self.profile {
DecoyProfile::RestApi | DecoyProfile::HealthCheck => "application/json",
DecoyProfile::Metrics => "text/plain; version=0.0.4; charset=utf-8",
}
}
pub fn http_response(&self) -> Vec<u8> {
let body = self.decoy_response();
let mut resp = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: {ct}\r\n\
Content-Length: {len}\r\n\
Server: nginx/1.25.4\r\n\
Connection: keep-alive\r\n",
ct = self.content_type(),
len = body.len(),
);
for (k, v) in &self.extra_headers {
resp.push_str(k);
resp.push_str(": ");
resp.push_str(v);
resp.push_str("\r\n");
}
resp.push_str("\r\n");
let mut bytes = resp.into_bytes();
bytes.extend_from_slice(&body);
bytes
}
pub fn inject_payload(&self, hidden: &[u8]) -> Vec<u8> {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(hidden);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match self.profile {
DecoyProfile::RestApi => format!(
r#"{{"status":"ok","version":"2.4.1","timestamp":{ts},"request_id":"{rid}","trace_id":"{b64}"}}"#,
rid = pseudo_request_id(ts),
)
.into_bytes(),
DecoyProfile::HealthCheck => format!(
r#"{{"healthy":true,"uptime_s":{uptime},"checks":{{"db":"pass","cache":"pass"}},"trace_id":"{b64}"}}"#,
uptime = ts % 864_000,
)
.into_bytes(),
DecoyProfile::Metrics => format!(
"# HELP http_requests_total Total HTTP requests.\n\
# TYPE http_requests_total counter\n\
http_requests_total{{method=\"GET\"}} {get}\n\
# trace {b64}\n\
process_uptime_seconds {uptime}\n",
get = ts % 100_000,
uptime = ts % 864_000,
)
.into_bytes(),
}
}
pub fn extract_payload(body: &[u8]) -> Option<Vec<u8>> {
use base64::Engine;
let text = std::str::from_utf8(body).ok()?;
if let Some(start) = text.find("\"trace_id\":\"") {
let val_start = start + "\"trace_id\":\"".len();
let val_end = text[val_start..].find('"')? + val_start;
let b64 = &text[val_start..val_end];
return base64::engine::general_purpose::STANDARD.decode(b64).ok();
}
for line in text.lines() {
if let Some(rest) = line.strip_prefix("# trace ") {
return base64::engine::general_purpose::STANDARD
.decode(rest.trim())
.ok();
}
}
None
}
}
pub struct DecoyRouter {
endpoints: Vec<DecoyEndpoint>,
}
impl DecoyRouter {
pub fn new() -> Self {
Self {
endpoints: Vec::new(),
}
}
pub fn add(&mut self, endpoint: DecoyEndpoint) {
self.endpoints.push(endpoint);
}
pub fn match_request(&self, method: &str, path: &str) -> Option<&DecoyEndpoint> {
self.endpoints.iter().find(|e| e.matches(method, path))
}
pub fn handle(&self, method: &str, path: &str) -> Vec<u8> {
match self.match_request(method, path) {
Some(ep) => ep.http_response(),
None => {
let body = b"{\"error\":\"not found\"}";
format!(
"HTTP/1.1 404 Not Found\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Server: nginx/1.25.4\r\n\
Connection: keep-alive\r\n\
\r\n",
body.len(),
)
.into_bytes()
.into_iter()
.chain(body.iter().copied())
.collect()
}
}
}
pub fn len(&self) -> usize {
self.endpoints.len()
}
pub fn is_empty(&self) -> bool {
self.endpoints.is_empty()
}
}
impl Default for DecoyRouter {
fn default() -> Self {
Self::new()
}
}
fn pseudo_request_id(ts: u64) -> String {
let h = ts.wrapping_mul(0x517cc1b727220a95);
format!("{h:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rest_api_response_is_valid_json() {
let ep = DecoyEndpoint::rest_api();
let body = ep.decoy_response();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("\"status\":\"ok\""));
assert!(text.contains("\"version\""));
assert!(text.contains("\"timestamp\""));
assert!(text.contains("\"request_id\""));
}
#[test]
fn health_check_response_is_valid_json() {
let ep = DecoyEndpoint::health_check();
let body = ep.decoy_response();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("\"healthy\":true"));
assert!(text.contains("\"uptime_s\""));
}
#[test]
fn metrics_response_has_prometheus_format() {
let ep = DecoyEndpoint::metrics();
let body = ep.decoy_response();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("# HELP"));
assert!(text.contains("# TYPE"));
assert!(text.contains("http_requests_total"));
assert!(text.contains("process_uptime_seconds"));
}
#[test]
fn matches_method_case_insensitive() {
let ep = DecoyEndpoint::rest_api();
assert!(ep.matches("GET", "/api/v1/status"));
assert!(ep.matches("get", "/api/v1/status"));
assert!(!ep.matches("POST", "/api/v1/status"));
assert!(!ep.matches("GET", "/other"));
}
#[test]
fn http_response_has_headers() {
let ep = DecoyEndpoint::rest_api().with_header("X-Custom", "test");
let resp = ep.http_response();
let text = std::str::from_utf8(&resp).unwrap();
assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(text.contains("Content-Type: application/json"));
assert!(text.contains("X-Custom: test"));
assert!(text.contains("\r\n\r\n"));
}
#[test]
fn content_type_matches_profile() {
assert_eq!(DecoyEndpoint::rest_api().content_type(), "application/json");
assert_eq!(
DecoyEndpoint::health_check().content_type(),
"application/json"
);
assert!(
DecoyEndpoint::metrics()
.content_type()
.starts_with("text/plain")
);
}
#[test]
fn inject_extract_roundtrip_json() {
let ep = DecoyEndpoint::rest_api();
let hidden = b"secret-signal-data";
let body = ep.inject_payload(hidden);
let recovered = DecoyEndpoint::extract_payload(&body).unwrap();
assert_eq!(recovered, hidden);
}
#[test]
fn inject_extract_roundtrip_health() {
let ep = DecoyEndpoint::health_check();
let hidden = b"\x00\x01\x02binary";
let body = ep.inject_payload(hidden);
let recovered = DecoyEndpoint::extract_payload(&body).unwrap();
assert_eq!(recovered, hidden);
}
#[test]
fn inject_extract_roundtrip_metrics() {
let ep = DecoyEndpoint::metrics();
let hidden = b"metrics-hidden";
let body = ep.inject_payload(hidden);
let recovered = DecoyEndpoint::extract_payload(&body).unwrap();
assert_eq!(recovered, hidden);
}
#[test]
fn extract_returns_none_for_plain_response() {
let ep = DecoyEndpoint::rest_api();
let body = ep.decoy_response();
assert!(DecoyEndpoint::extract_payload(&body).is_none());
}
#[test]
fn router_matches_and_returns_404() {
let mut router = DecoyRouter::new();
router.add(DecoyEndpoint::rest_api());
router.add(DecoyEndpoint::health_check());
assert!(router.match_request("GET", "/api/v1/status").is_some());
assert!(router.match_request("GET", "/healthz").is_some());
assert!(router.match_request("GET", "/unknown").is_none());
let resp = router.handle("GET", "/unknown");
let text = std::str::from_utf8(&resp).unwrap();
assert!(text.starts_with("HTTP/1.1 404"));
}
#[test]
fn router_len_and_is_empty() {
let mut router = DecoyRouter::new();
assert!(router.is_empty());
assert_eq!(router.len(), 0);
router.add(DecoyEndpoint::metrics());
assert!(!router.is_empty());
assert_eq!(router.len(), 1);
}
}