diff --git a/src/lib.rs b/src/lib.rs index 54d7fb2..601e534 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,5 @@ pub mod request; +pub mod response; + pub use request::Request; +pub use response::Response; diff --git a/src/request/titan.rs b/src/request/titan.rs index 37b3888..2c47c85 100644 --- a/src/request/titan.rs +++ b/src/request/titan.rs @@ -104,7 +104,7 @@ impl<'a> Titan<'a> { } #[test] -fn test_bytes() { +fn test() { const DATA: &[u8] = &[1, 2, 3]; const MIME: &str = "plain/text"; const TOKEN: &str = "token"; diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..843eccd --- /dev/null +++ b/src/response.rs @@ -0,0 +1,366 @@ +pub mod certificate; +pub mod failure; +pub mod input; +pub mod redirect; +pub mod success; + +pub use certificate::Certificate; +pub use failure::Failure; +pub use input::Input; +pub use redirect::Redirect; +pub use success::Success; + +use anyhow::{bail, Result}; + +/// [Gemini](https://geminiprotocol.net/docs/protocol-specification.gmi) source +pub enum Response { + Certificate(Certificate), + Failure(Failure), + Input(Input), + Redirect(Redirect), + Success(Success), +} + +impl Response { + pub fn from_bytes(buffer: &[u8]) -> Result { + match buffer.first() { + Some(byte) => Ok(match byte { + b'1' => Self::Input(Input::from_bytes(buffer)?), + b'2' => Self::Success(Success::from_bytes(buffer)?), + b'3' => Self::Redirect(Redirect::from_bytes(buffer)?), + b'4' | b'5' => Self::Failure(Failure::from_bytes(buffer)?), + b'6' => Self::Certificate(Certificate::from_bytes(buffer)?), + b => bail!("Unspecified header byte: {b}"), + }), + None => bail!("Empty source"), + } + } + + pub fn into_bytes(self) -> Vec { + match self { + Self::Certificate(this) => this.into_bytes(), + Self::Failure(this) => this.into_bytes(), + Self::Input(this) => this.into_bytes(), + Self::Redirect(this) => this.into_bytes(), + Self::Success(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 10 + { + let source = format!("10 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Input(ref this) => match this { + Input::Default(this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + { + let source = format!("11 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Input(ref this) => match this { + Input::Sensitive(this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 20 + { + let source = format!("20 text/gemini\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Success(ref this) => match this { + Success::Default(this) => assert_eq!(this.mime, "text/gemini".to_string()), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 30 + { + let source = format!("30 target\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Redirect(ref this) => match this { + Redirect::Temporary(this) => assert_eq!(this.target, "target".to_string()), + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 31 + { + let source = format!("31 target\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Redirect(ref this) => match this { + Redirect::Permanent(this) => assert_eq!(this.target, "target".to_string()), + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 4* + { + use failure::Temporary; + // 40 + { + let source = format!("40 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Temporary(this) => match this { + Temporary::General(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 41 + { + let source = format!("41 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Temporary(this) => match this { + Temporary::ServerUnavailable(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 42 + { + let source = format!("42 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Temporary(this) => match this { + Temporary::CgiError(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 43 + { + let source = format!("43 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Temporary(this) => match this { + Temporary::ProxyError(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 44 + { + let source = format!("44 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Temporary(this) => match this { + Temporary::SlowDown(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + } + // 5* + { + use failure::Permanent; + // 50 + { + let source = format!("50 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Permanent(this) => match this { + Permanent::General(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 51 + { + let source = format!("51 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Permanent(this) => match this { + Permanent::NotFound(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 52 + { + let source = format!("52 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Permanent(this) => match this { + Permanent::Gone(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 53 + { + let source = format!("53 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Permanent(this) => match this { + Permanent::ProxyRequestRefused(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 59 + { + let source = format!("59 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Failure(ref this) => match this { + Failure::Permanent(this) => match this { + Permanent::BadRequest(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + } + // 60 + { + let source = format!("60 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Certificate(ref this) => match this { + Certificate::Expected(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 61 + { + let source = format!("61 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Certificate(ref this) => match this { + Certificate::NotAuthorized(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 62 + { + let source = format!("62 message\r\n"); + let target = Response::from_bytes(source.as_bytes()).unwrap(); + + match target { + Response::Certificate(ref this) => match this { + Certificate::NotValid(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } +} diff --git a/src/response/certificate.rs b/src/response/certificate.rs new file mode 100644 index 0000000..b7928da --- /dev/null +++ b/src/response/certificate.rs @@ -0,0 +1,75 @@ +pub mod expected; +pub mod not_authorized; +pub mod not_valid; + +pub use expected::Expected; +pub use not_authorized::NotAuthorized; +pub use not_valid::NotValid; + +use anyhow::{bail, Result}; + +/// [Client certificates](https://geminiprotocol.net/docs/protocol-specification.gmi#client-certificates) +pub enum Certificate { + Expected(Expected), + NotAuthorized(NotAuthorized), + NotValid(NotValid), +} + +impl Certificate { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'6') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::Expected(Expected::from_bytes(buffer)?), + b'1' => Self::NotAuthorized(NotAuthorized::from_bytes(buffer)?), + b'2' => Self::NotValid(NotValid::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::Expected(this) => this.into_bytes(), + Self::NotAuthorized(this) => this.into_bytes(), + Self::NotValid(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 60 + let request = format!("60 message\r\n"); + let source = Certificate::from_bytes(request.as_bytes()).unwrap(); + + match source { + Certificate::Expected(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + + // 61 + let request = format!("61 message\r\n"); + let source = Certificate::from_bytes(request.as_bytes()).unwrap(); + + match source { + Certificate::NotAuthorized(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + + // 62 + let request = format!("62 message\r\n"); + let source = Certificate::from_bytes(request.as_bytes()).unwrap(); + + match source { + Certificate::NotValid(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/certificate/expected.rs b/src/response/certificate/expected.rs new file mode 100644 index 0000000..63bd266 --- /dev/null +++ b/src/response/certificate/expected.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"60"; + +/// [Certificate expected](https://geminiprotocol.net/docs/protocol-specification.gmi#status-60) +pub struct Expected { + pub message: Option, +} + +impl Expected { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("60 message\r\n"); + let source = Expected::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/certificate/not_authorized.rs b/src/response/certificate/not_authorized.rs new file mode 100644 index 0000000..f8606f8 --- /dev/null +++ b/src/response/certificate/not_authorized.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"61"; + +/// [Certificate authorization](https://geminiprotocol.net/docs/protocol-specification.gmi#status-61-certificate-not-authorized) +pub struct NotAuthorized { + pub message: Option, +} + +impl NotAuthorized { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("61 message\r\n"); + let source = NotAuthorized::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/certificate/not_valid.rs b/src/response/certificate/not_valid.rs new file mode 100644 index 0000000..cf89e62 --- /dev/null +++ b/src/response/certificate/not_valid.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"62"; + +/// [Certificate invalid](https://geminiprotocol.net/docs/protocol-specification.gmi#status-62-certificate-not-valid) +pub struct NotValid { + pub message: Option, +} + +impl NotValid { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("62 message\r\n"); + let source = NotValid::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure.rs b/src/response/failure.rs new file mode 100644 index 0000000..704c091 --- /dev/null +++ b/src/response/failure.rs @@ -0,0 +1,203 @@ +pub mod permanent; +pub mod temporary; + +pub use permanent::Permanent; +pub use temporary::Temporary; + +use anyhow::{bail, Result}; + +pub enum Failure { + /// [Permanent failure](https://geminiprotocol.net/docs/protocol-specification.gmi#permanent-failure) + Permanent(Permanent), + /// [Temporary failure](https://geminiprotocol.net/docs/protocol-specification.gmi#temporary-failure) + Temporary(Temporary), +} + +impl Failure { + pub fn from_bytes(buffer: &[u8]) -> Result { + match buffer.first() { + Some(byte) => Ok(match byte { + b'4' => Self::Temporary(Temporary::from_bytes(buffer)?), + b'5' => Self::Permanent(Permanent::from_bytes(buffer)?), + b => bail!("Unexpected first byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::Temporary(this) => this.into_bytes(), + Self::Permanent(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 4* + { + // 40 + { + let request = format!("40 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Temporary(ref this) => match this { + Temporary::General(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 41 + { + let request = format!("41 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Temporary(ref this) => match this { + Temporary::ServerUnavailable(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 42 + { + let request = format!("42 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Temporary(ref this) => match this { + Temporary::CgiError(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 43 + { + let request = format!("43 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Temporary(ref this) => match this { + Temporary::ProxyError(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 44 + { + let request = format!("44 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Temporary(ref this) => match this { + Temporary::SlowDown(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + } + // 5* + { + // 50 + { + let request = format!("50 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Permanent(ref this) => match this { + Permanent::General(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 51 + { + let request = format!("51 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Permanent(ref this) => match this { + Permanent::NotFound(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 52 + { + let request = format!("52 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Permanent(ref this) => match this { + Permanent::Gone(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 53 + { + let request = format!("53 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Permanent(ref this) => match this { + Permanent::ProxyRequestRefused(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + // 59 + { + let request = format!("59 message\r\n"); + let source = Failure::from_bytes(request.as_bytes()).unwrap(); + + match source { + Failure::Permanent(ref this) => match this { + Permanent::BadRequest(this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + }, + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + } + } +} diff --git a/src/response/failure/permanent.rs b/src/response/failure/permanent.rs new file mode 100644 index 0000000..2f2bc11 --- /dev/null +++ b/src/response/failure/permanent.rs @@ -0,0 +1,118 @@ +pub mod bad_request; +pub mod general; +pub mod gone; +pub mod not_found; +pub mod proxy_request_refused; + +pub use bad_request::BadRequest; +pub use general::General; +pub use gone::Gone; +pub use not_found::NotFound; +pub use proxy_request_refused::ProxyRequestRefused; + +use anyhow::{bail, Result}; + +/// [Permanent failure](https://geminiprotocol.net/docs/protocol-specification.gmi#permanent-failure) +pub enum Permanent { + /// [General permanent failure](https://geminiprotocol.net/docs/protocol-specification.gmi#status-50) + General(General), + /// [Not found](https://geminiprotocol.net/docs/protocol-specification.gmi#status-51-not-found) + NotFound(NotFound), + /// [Gone](https://geminiprotocol.net/docs/protocol-specification.gmi#status-52-gone) + Gone(Gone), + /// [Proxy request refused](https://geminiprotocol.net/docs/protocol-specification.gmi#status-53-proxy-request-refused) + ProxyRequestRefused(ProxyRequestRefused), + /// [Bad request](https://geminiprotocol.net/docs/protocol-specification.gmi#status-59-bad-request) + BadRequest(BadRequest), +} + +impl Permanent { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'5') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::General(General::from_bytes(buffer)?), + b'1' => Self::NotFound(NotFound::from_bytes(buffer)?), + b'2' => Self::Gone(Gone::from_bytes(buffer)?), + b'3' => Self::ProxyRequestRefused(ProxyRequestRefused::from_bytes(buffer)?), + b'9' => Self::BadRequest(BadRequest::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::General(this) => this.into_bytes(), + Self::NotFound(this) => this.into_bytes(), + Self::Gone(this) => this.into_bytes(), + Self::ProxyRequestRefused(this) => this.into_bytes(), + Self::BadRequest(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 50 + { + let source = format!("50 message\r\n"); + let target = Permanent::from_bytes(source.as_bytes()).unwrap(); + + match target { + Permanent::General(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 51 + { + let source = format!("51 message\r\n"); + let target = Permanent::from_bytes(source.as_bytes()).unwrap(); + + match target { + Permanent::NotFound(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 52 + { + let source = format!("52 message\r\n"); + let target = Permanent::from_bytes(source.as_bytes()).unwrap(); + + match target { + Permanent::Gone(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 53 + { + let source = format!("53 message\r\n"); + let target = Permanent::from_bytes(source.as_bytes()).unwrap(); + + match target { + Permanent::ProxyRequestRefused(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 59 + { + let source = format!("59 message\r\n"); + let target = Permanent::from_bytes(source.as_bytes()).unwrap(); + + match target { + Permanent::BadRequest(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } +} diff --git a/src/response/failure/permanent/bad_request.rs b/src/response/failure/permanent/bad_request.rs new file mode 100644 index 0000000..ea413cf --- /dev/null +++ b/src/response/failure/permanent/bad_request.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"59"; + +/// [Bad request](https://geminiprotocol.net/docs/protocol-specification.gmi#status-59-bad-request) +pub struct BadRequest { + pub message: Option, +} + +impl BadRequest { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("59 message\r\n"); + let source = BadRequest::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/permanent/general.rs b/src/response/failure/permanent/general.rs new file mode 100644 index 0000000..b0c2e6e --- /dev/null +++ b/src/response/failure/permanent/general.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"50"; + +/// [General permanent failure code](https://geminiprotocol.net/docs/protocol-specification.gmi#status-50) +pub struct General { + pub message: Option, +} + +impl General { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("50 message\r\n"); + let source = General::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/permanent/gone.rs b/src/response/failure/permanent/gone.rs new file mode 100644 index 0000000..de24b62 --- /dev/null +++ b/src/response/failure/permanent/gone.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"52"; + +/// [Gone](https://geminiprotocol.net/docs/protocol-specification.gmi#status-52-gone) +pub struct Gone { + pub message: Option, +} + +impl Gone { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("52 message\r\n"); + let source = Gone::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/permanent/not_found.rs b/src/response/failure/permanent/not_found.rs new file mode 100644 index 0000000..c9ada00 --- /dev/null +++ b/src/response/failure/permanent/not_found.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"51"; + +/// [Not found](https://geminiprotocol.net/docs/protocol-specification.gmi#status-51-not-found) +pub struct NotFound { + pub message: Option, +} + +impl NotFound { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("51 message\r\n"); + let source = NotFound::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/permanent/proxy_request_refused.rs b/src/response/failure/permanent/proxy_request_refused.rs new file mode 100644 index 0000000..477213e --- /dev/null +++ b/src/response/failure/permanent/proxy_request_refused.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"53"; + +/// [Proxy request refused](https://geminiprotocol.net/docs/protocol-specification.gmi#status-53-proxy-request-refused) +pub struct ProxyRequestRefused { + pub message: Option, +} + +impl ProxyRequestRefused { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("53 message\r\n"); + let source = ProxyRequestRefused::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/temporary.rs b/src/response/failure/temporary.rs new file mode 100644 index 0000000..e50f87a --- /dev/null +++ b/src/response/failure/temporary.rs @@ -0,0 +1,119 @@ +pub mod cgi_error; +pub mod general; +pub mod proxy_error; +pub mod server_unavailable; +pub mod slow_down; + +pub use cgi_error::CgiError; +pub use general::General; +pub use proxy_error::ProxyError; +pub use server_unavailable::ServerUnavailable; +pub use slow_down::SlowDown; + +use anyhow::{bail, Result}; + +pub enum Temporary { + /// [General temporary failure](https://geminiprotocol.net/docs/protocol-specification.gmi#status-40) + General(General), + /// [Server unavailable](https://geminiprotocol.net/docs/protocol-specification.gmi#status-41-server-unavailable) + ServerUnavailable(ServerUnavailable), + /// [CGI error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-42-cgi-error) + CgiError(CgiError), + /// [Proxy error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-43-proxy-error) + ProxyError(ProxyError), + /// [Slow down](https://geminiprotocol.net/docs/protocol-specification.gmi#status-44-slow-down) + SlowDown(SlowDown), +} + +impl Temporary { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'4') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::General(General::from_bytes(buffer)?), + b'1' => Self::ServerUnavailable(ServerUnavailable::from_bytes(buffer)?), + b'2' => Self::CgiError(CgiError::from_bytes(buffer)?), + b'3' => Self::ProxyError(ProxyError::from_bytes(buffer)?), + b'4' => Self::SlowDown(SlowDown::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::General(this) => this.into_bytes(), + Self::ServerUnavailable(this) => this.into_bytes(), + Self::CgiError(this) => this.into_bytes(), + Self::ProxyError(this) => this.into_bytes(), + Self::SlowDown(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 40 + { + let source = format!("40 message\r\n"); + let target = Temporary::from_bytes(source.as_bytes()).unwrap(); + + match target { + Temporary::General(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 41 + { + let source = format!("41 message\r\n"); + let target = Temporary::from_bytes(source.as_bytes()).unwrap(); + + match target { + Temporary::ServerUnavailable(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 42 + { + let source = format!("42 message\r\n"); + let target = Temporary::from_bytes(source.as_bytes()).unwrap(); + + match target { + Temporary::CgiError(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 43 + { + let source = format!("43 message\r\n"); + let target = Temporary::from_bytes(source.as_bytes()).unwrap(); + + match target { + Temporary::ProxyError(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } + // 44 + { + let source = format!("44 message\r\n"); + let target = Temporary::from_bytes(source.as_bytes()).unwrap(); + + match target { + Temporary::SlowDown(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(target.into_bytes(), source.as_bytes()); + } +} diff --git a/src/response/failure/temporary/cgi_error.rs b/src/response/failure/temporary/cgi_error.rs new file mode 100644 index 0000000..de3e85e --- /dev/null +++ b/src/response/failure/temporary/cgi_error.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"42"; + +/// [CGI error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-42-cgi-error) +pub struct CgiError { + pub message: Option, +} + +impl CgiError { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("42 message\r\n"); + let source = CgiError::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/temporary/general.rs b/src/response/failure/temporary/general.rs new file mode 100644 index 0000000..36bf485 --- /dev/null +++ b/src/response/failure/temporary/general.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"40"; + +/// [General temporary failure code](https://geminiprotocol.net/docs/protocol-specification.gmi#status-40) +pub struct General { + pub message: Option, +} + +impl General { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("40 message\r\n"); + let source = General::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/temporary/proxy_error.rs b/src/response/failure/temporary/proxy_error.rs new file mode 100644 index 0000000..8bcf8a9 --- /dev/null +++ b/src/response/failure/temporary/proxy_error.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"43"; + +/// [Proxy error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-43-proxy-error) +pub struct ProxyError { + pub message: Option, +} + +impl ProxyError { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("43 message\r\n"); + let source = ProxyError::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/temporary/server_unavailable.rs b/src/response/failure/temporary/server_unavailable.rs new file mode 100644 index 0000000..8fa09ab --- /dev/null +++ b/src/response/failure/temporary/server_unavailable.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"41"; + +/// [Server unavailable](https://geminiprotocol.net/docs/protocol-specification.gmi#status-41-server-unavailable) +pub struct ServerUnavailable { + pub message: Option, +} + +impl ServerUnavailable { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("41 message\r\n"); + let source = ServerUnavailable::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/failure/temporary/slow_down.rs b/src/response/failure/temporary/slow_down.rs new file mode 100644 index 0000000..a8bf876 --- /dev/null +++ b/src/response/failure/temporary/slow_down.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"44"; + +/// [Slow down](https://geminiprotocol.net/docs/protocol-specification.gmi#status-44-slow-down) +pub struct SlowDown { + pub message: Option, +} + +impl SlowDown { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("44 message\r\n"); + let source = SlowDown::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/input.rs b/src/response/input.rs new file mode 100644 index 0000000..7bb9dee --- /dev/null +++ b/src/response/input.rs @@ -0,0 +1,60 @@ +pub mod default; +pub mod sensitive; + +pub use default::Default; +pub use sensitive::Sensitive; + +use anyhow::{bail, Result}; + +/// [Input expected](https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected) +pub enum Input { + Default(Default), + Sensitive(Sensitive), +} + +impl Input { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'1') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::Default(Default::from_bytes(buffer)?), + b'1' => Self::Sensitive(Sensitive::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::Default(this) => this.into_bytes(), + Self::Sensitive(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 10 + let request = format!("10 message\r\n"); + let source = Input::from_bytes(request.as_bytes()).unwrap(); + + match source { + Input::Default(ref this) => assert_eq!(this.message, Some("message".to_string())), + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + + // 11 + let request = format!("11 message\r\n"); + let source = Input::from_bytes(request.as_bytes()).unwrap(); + + match source { + Input::Sensitive(ref this) => { + assert_eq!(this.message, Some("message".to_string())) + } + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/input/default.rs b/src/response/input/default.rs new file mode 100644 index 0000000..c2181ea --- /dev/null +++ b/src/response/input/default.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"10"; + +/// [Default input](https://geminiprotocol.net/docs/protocol-specification.gmi#status-10) +pub struct Default { + pub message: Option, +} + +impl Default { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("10 message\r\n"); + let source = Default::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/input/sensitive.rs b/src/response/input/sensitive.rs new file mode 100644 index 0000000..562bc82 --- /dev/null +++ b/src/response/input/sensitive.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"11"; + +/// [Sensitive input](https://geminiprotocol.net/docs/protocol-specification.gmi#status-11-sensitive-input) +pub struct Sensitive { + pub message: Option, +} + +impl Sensitive { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + Ok(Self { + message: String::from_utf8(m).map(|m| if m.is_empty() { None } else { Some(m) })?, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + match self.message { + Some(message) => { + let mut bytes = Vec::with_capacity(message.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(message.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } + None => { + let mut bytes = Vec::with_capacity(4); + bytes.extend(CODE); + bytes.extend([b'\r', b'\n']); + bytes + } + } + } +} + +#[test] +fn test() { + let request = format!("11 message\r\n"); + let source = Sensitive::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.message, Some("message".to_string())); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/redirect.rs b/src/response/redirect.rs new file mode 100644 index 0000000..0b26241 --- /dev/null +++ b/src/response/redirect.rs @@ -0,0 +1,60 @@ +pub mod permanent; +pub mod temporary; + +pub use permanent::Permanent; +pub use temporary::Temporary; + +use anyhow::{bail, Result}; + +/// [Redirect](https://geminiprotocol.net/docs/protocol-specification.gmi#redirection) +pub enum Redirect { + Permanent(Permanent), + Temporary(Temporary), +} + +impl Redirect { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'3') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::Temporary(Temporary::from_bytes(buffer)?), + b'1' => Self::Permanent(Permanent::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::Permanent(this) => this.into_bytes(), + Self::Temporary(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + // 30 + let request = format!("30 message\r\n"); + let source = Redirect::from_bytes(request.as_bytes()).unwrap(); + + match source { + Redirect::Temporary(ref this) => { + assert_eq!(this.target, "message".to_string()) + } + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); + + // 31 + let request = format!("31 message\r\n"); + let source = Redirect::from_bytes(request.as_bytes()).unwrap(); + + match source { + Redirect::Permanent(ref this) => assert_eq!(this.target, "message".to_string()), + _ => panic!(), + } + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/redirect/permanent.rs b/src/response/redirect/permanent.rs new file mode 100644 index 0000000..60cbaa0 --- /dev/null +++ b/src/response/redirect/permanent.rs @@ -0,0 +1,65 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"31"; + +/// [Permanent redirect](https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection) +pub struct Permanent { + pub target: String, +} + +impl Permanent { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut t = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + t.push(*b) + } + + Ok(Self { + target: if t.is_empty() { + bail!("Target required") + } else { + String::from_utf8(t)? + }, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + let mut bytes = Vec::with_capacity(self.target.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(self.target.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } +} + +#[test] +fn test() { + let request = format!("31 target\r\n"); + let source = Permanent::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.target, "target".to_string()); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/redirect/temporary.rs b/src/response/redirect/temporary.rs new file mode 100644 index 0000000..25e3326 --- /dev/null +++ b/src/response/redirect/temporary.rs @@ -0,0 +1,65 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"30"; + +/// [Temporary redirect](https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection) +pub struct Temporary { + pub target: String, +} + +impl Temporary { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut t = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + t.push(*b) + } + + Ok(Self { + target: if t.is_empty() { + bail!("Target required") + } else { + String::from_utf8(t)? + }, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + let mut bytes = Vec::with_capacity(self.target.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(self.target.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } +} + +#[test] +fn test() { + let request = format!("30 target\r\n"); + let source = Temporary::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.target, "target".to_string()); + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/success.rs b/src/response/success.rs new file mode 100644 index 0000000..3f3e44a --- /dev/null +++ b/src/response/success.rs @@ -0,0 +1,42 @@ +pub mod default; +pub use default::Default; + +use anyhow::{bail, Result}; + +/// [Success](https://geminiprotocol.net/docs/protocol-specification.gmi#success) +pub enum Success { + Default(Default), +} + +impl Success { + pub fn from_bytes(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != b'2') { + bail!("Unexpected first byte") + } + match buffer.get(1) { + Some(byte) => Ok(match byte { + b'0' => Self::Default(Default::from_bytes(buffer)?), + b => bail!("Unexpected second byte: {b}"), + }), + None => bail!("Invalid request"), + } + } + pub fn into_bytes(self) -> Vec { + match self { + Self::Default(this) => this.into_bytes(), + } + } +} + +#[test] +fn test() { + let request = format!("20 text/gemini\r\n"); + let source = Success::from_bytes(request.as_bytes()).unwrap(); + + match source { + Success::Default(ref this) => { + assert_eq!(this.mime, "text/gemini".to_string()) + } + } + assert_eq!(source.into_bytes(), request.as_bytes()); +} diff --git a/src/response/success/default.rs b/src/response/success/default.rs new file mode 100644 index 0000000..7d443cb --- /dev/null +++ b/src/response/success/default.rs @@ -0,0 +1,65 @@ +use anyhow::{bail, Result}; + +pub const CODE: &[u8] = b"20"; + +/// [Success](https://geminiprotocol.net/docs/protocol-specification.gmi#success) +pub struct Default { + pub mime: String, +} + +impl Default { + /// Build `Self` from UTF-8 header bytes + /// * expected buffer includes leading status code, message, CRLF + pub fn from_bytes(buffer: &[u8]) -> Result { + // calculate length once + let len = buffer.len(); + // validate headers for this response type + if !(3..=1024).contains(&len) { + bail!("Unexpected header length") + } + if buffer + .get(..2) + .is_none_or(|c| c[0] != CODE[0] || c[1] != CODE[1]) + { + bail!("Invalid status code") + } + // collect data bytes + let mut m = Vec::with_capacity(len); + for b in buffer[3..].iter() { + if *b == b'\r' { + continue; + } + if *b == b'\n' { + break; + } + m.push(*b) + } + + Ok(Self { + mime: if m.is_empty() { + bail!("Content type required") + } else { + String::from_utf8(m)? + }, + }) + } + + /// Convert `Self` into UTF-8 bytes presentation + pub fn into_bytes(self) -> Vec { + let mut bytes = Vec::with_capacity(self.mime.len() + 5); + bytes.extend(CODE); + bytes.push(b' '); + bytes.extend(self.mime.into_bytes()); + bytes.extend([b'\r', b'\n']); + bytes + } +} + +#[test] +fn test() { + let request = format!("20 text/gemini\r\n"); + let source = Default::from_bytes(request.as_bytes()).unwrap(); + + assert_eq!(source.mime, "text/gemini".to_string()); + assert_eq!(source.into_bytes(), request.as_bytes()); +}