use bytes::Bytes;
#[derive(Debug, Clone)]
pub struct ForgeRequest {
pub method: String,
pub path: String,
pub query: String,
pub headers: Vec<(String, String)>,
pub raw_body: Bytes,
pub host_url: Option<String>,
}
impl ForgeRequest {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn absolute_url(&self) -> String {
let base = self
.host_url
.clone()
.unwrap_or_else(|| "http://localhost".to_string());
if self.query.is_empty() {
format!("{base}{}", self.path)
} else {
format!("{base}{}?{}", self.path, self.query)
}
}
#[must_use]
pub fn query_param(&self, key: &str) -> Option<String> {
for pair in self.query.split('&') {
let mut it = pair.splitn(2, '=');
let k = it.next().unwrap_or("");
if k == key {
let v = it.next().unwrap_or("");
return Some(v.replace('+', " "));
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct ForgeResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Bytes,
}
impl ForgeResponse {
#[must_use]
pub fn with_type(status: u16, content_type: &str, body: impl Into<Bytes>) -> Self {
Self {
status,
headers: vec![("content-type".into(), content_type.into())],
body: body.into(),
}
}
#[must_use]
pub fn html(status: u16, body: impl Into<String>) -> Self {
Self::with_type(
status,
"text/html; charset=utf-8",
Bytes::from(body.into()),
)
}
#[must_use]
pub fn json(status: u16, value: &serde_json::Value) -> Self {
let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
Self::with_type(status, "application/json", Bytes::from(body))
}
#[must_use]
pub fn raw_bytes(bytes: Bytes, is_text: bool, filename: &str) -> Self {
if is_text {
Self {
status: 200,
headers: vec![
("content-type".into(), "text/plain; charset=utf-8".into()),
("x-content-type-options".into(), "nosniff".into()),
],
body: bytes,
}
} else {
let safe = sanitize_filename(filename);
Self {
status: 200,
headers: vec![
("content-type".into(), "application/octet-stream".into()),
(
"content-disposition".into(),
format!("attachment; filename=\"{safe}\""),
),
("x-content-type-options".into(), "nosniff".into()),
],
body: bytes,
}
}
}
#[must_use]
pub fn redirect(status: u16, location: &str) -> Self {
Self {
status,
headers: vec![("location".into(), location.to_string())],
body: Bytes::new(),
}
}
#[must_use]
pub fn error(status: u16, msg: impl Into<String>) -> Self {
let v = serde_json::json!({ "error": msg.into() });
Self::json(status, &v)
}
#[must_use]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.into(), value.into()));
self
}
}
#[must_use]
pub fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[must_use]
pub fn sanitize_filename(name: &str) -> String {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
let cleaned: String = base
.chars()
.filter(|c| !c.is_control() && *c != '"' && *c != '\\')
.take(200)
.collect();
if cleaned.is_empty() {
"download".to_string()
} else {
cleaned
}
}
#[must_use]
pub fn looks_textual(bytes: &[u8]) -> bool {
if bytes.contains(&0) {
return false;
}
std::str::from_utf8(bytes).is_ok()
}
#[must_use]
pub fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
match (hi, lo) {
(Some(h), Some(l)) => {
out.push((h * 16 + l) as u8);
i += 3;
}
_ => {
out.push(b'%');
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[must_use]
pub fn parse_form(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for pair in body.split('&') {
if pair.is_empty() {
continue;
}
let mut it = pair.splitn(2, '=');
let k = percent_decode(it.next().unwrap_or(""));
let v = percent_decode(it.next().unwrap_or(""));
out.push((k, v));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn esc_neutralises_script_payload() {
let raw = r#"<script>alert('xss')</script>"#;
let out = esc(raw);
assert!(!out.contains('<'));
assert!(!out.contains('>'));
assert_eq!(
out,
"<script>alert('xss')</script>"
);
}
#[test]
fn esc_escapes_ampersand_once() {
assert_eq!(esc("a & b < c"), "a & b < c");
assert_eq!(esc("<"), "&lt;");
}
#[test]
fn esc_escapes_quotes() {
assert_eq!(esc(r#"say "hi""#), "say "hi"");
}
#[test]
fn raw_bytes_binary_is_attachment() {
let r = ForgeResponse::raw_bytes(Bytes::from_static(&[0u8, 1, 2]), false, "evil.html");
let ct = r
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str())
.unwrap();
assert_eq!(ct, "application/octet-stream");
assert!(r
.headers
.iter()
.any(|(k, v)| k == "content-disposition" && v.contains("attachment")));
}
#[test]
fn raw_bytes_text_is_plain() {
let r = ForgeResponse::raw_bytes(Bytes::from_static(b"hello"), true, "a.txt");
let ct = r
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str())
.unwrap();
assert!(ct.starts_with("text/plain"));
}
#[test]
fn sanitize_filename_strips_path_and_quotes() {
assert_eq!(sanitize_filename("../../etc/pa\"ss"), "pass");
assert_eq!(sanitize_filename(""), "download");
assert_eq!(sanitize_filename("a/b/c.tar.gz"), "c.tar.gz");
}
#[test]
fn looks_textual_rejects_nul() {
assert!(looks_textual(b"plain text"));
assert!(!looks_textual(&[b'a', 0, b'b']));
}
#[test]
fn query_param_parses_first_and_plus() {
let req = ForgeRequest {
method: "GET".into(),
path: "/forge/a/b/issues".into(),
query: "state=open&q=hello+world".into(),
headers: vec![],
raw_body: Bytes::new(),
host_url: None,
};
assert_eq!(req.query_param("state").as_deref(), Some("open"));
assert_eq!(req.query_param("q").as_deref(), Some("hello world"));
assert_eq!(req.query_param("missing"), None);
}
#[test]
fn percent_decode_and_form() {
assert_eq!(percent_decode("a%2Fb+c"), "a/b c");
assert_eq!(percent_decode("%zz"), "%zz");
let pairs = parse_form("title=Fix+bug&resourceUrl=https%3A%2F%2Fp%2Fx.jsonld");
assert_eq!(pairs[0], ("title".into(), "Fix bug".into()));
assert_eq!(
pairs[1],
("resourceUrl".into(), "https://p/x.jsonld".into())
);
}
#[test]
fn header_lookup_is_case_insensitive() {
let req = ForgeRequest {
method: "GET".into(),
path: "/".into(),
query: String::new(),
headers: vec![("Authorization".into(), "Nostr xyz".into())],
raw_body: Bytes::new(),
host_url: None,
};
assert_eq!(req.header("authorization"), Some("Nostr xyz"));
assert_eq!(req.header("AUTHORIZATION"), Some("Nostr xyz"));
}
}