diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad7ed4d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Update packages index + run: sudo apt update + - name: Install system packages + run: sudo apt install -y libglib2.0-dev + - name: Run clippy + run: cargo clippy --all-targets + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index e1ae956..801fb2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,20 @@ [package] name = "ggemini" -version = "0.6.0" -edition = "2021" +version = "0.20.1" +edition = "2024" license = "MIT" readme = "README.md" -description = "Glib-oriented client for Gemini protocol" -keywords = ["gemini", "gemini-protocol", "gtk", "gio", "client"] +description = "Glib/Gio-oriented network API for Gemini protocol" +keywords = ["gemini", "titan", "glib", "gio", "client"] categories = ["development-tools", "network-programming", "parsing"] repository = "https://github.com/YGGverse/ggemini" [dependencies.gio] package = "gio" -version = "0.20.4" +version = "0.21.0" +features = ["v2_70"] [dependencies.glib] package = "glib" -version = "0.20.4" +version = "0.21.0" features = ["v2_66"] diff --git a/README.md b/README.md index e2cf971..03c633e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,30 @@ # ggemini +![Build](https://github.com/YGGverse/ggemini/actions/workflows/build.yml/badge.svg) +[![Documentation](https://docs.rs/ggemini/badge.svg)](https://docs.rs/ggemini) +[![crates.io](https://img.shields.io/crates/v/ggemini.svg)](https://crates.io/crates/ggemini) + Glib/Gio-oriented network API for [Gemini protocol](https://geminiprotocol.net/) > [!IMPORTANT] > Project in development! > -GGemini (or G-Gemini) library written as the client extension for [Yoda](https://github.com/YGGverse/Yoda) - GTK Browser for Gemini Protocol, -it also could be useful for any other integrations as depend of [glib](https://crates.io/crates/glib) and [gio](https://crates.io/crates/gio) (`2.66+`) crates only +GGemini (or G-Gemini) library written as the client extension for [Yoda](https://github.com/YGGverse/Yoda), it also could be useful for other GTK-based applications dependent of [glib](https://crates.io/crates/glib) / [gio](https://crates.io/crates/gio) (`2.66+`) backend. + +## Requirements + +
+Debian +
+sudo apt install libglib2.0-dev
+
+ +
+Fedora +
+sudo dnf install glib2-devel
+
## Install @@ -19,11 +36,42 @@ cargo add ggemini * [Documentation](https://docs.rs/ggemini/latest/ggemini/) -_todo_ +### Example -### `client` -### `gio` +``` rust +use gio::*; +use glib::*; -## See also +use ggemini::client::{ + connection::{request::{Mode, Request}, Response}, + Client, +}; + +fn main() -> ExitCode { + Client::new().request_async( + Request::Gemini { // or `Request::Titan` + uri: Uri::parse("gemini://geminiprotocol.net/", UriFlags::NONE).unwrap(), + mode: Mode::HeaderOnly // handle content separately (based on MIME) + }, + Priority::DEFAULT, + Cancellable::new(), + None, // optional auth `GTlsCertificate` + None, // optional TOFU `GTlsCertificate` array + |result| match result { + Ok((response, _connection)) => match response { + Response::Success(success) => match success.mime().unwrap().as_str() { + "text/gemini" => todo!(), + _ => todo!(), + }, + _ => todo!(), + }, + Err(_) => todo!(), + }, + ); + ExitCode::SUCCESS +} +``` + +## Other crates * [ggemtext](https://github.com/YGGverse/ggemtext) - Glib-oriented Gemtext API \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index 4c6f2cd..2152781 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1 +1,115 @@ -pub mod response; +//! High-level client API to interact with Gemini Socket Server: +//! * https://geminiprotocol.net/docs/protocol-specification.gmi + +pub mod connection; +pub mod error; + +pub use connection::{Connection, Request, Response}; +pub use error::Error; + +use gio::{Cancellable, SocketClient, SocketProtocol, TlsCertificate, prelude::SocketClientExt}; +use glib::Priority; + +// Defaults + +pub const DEFAULT_TIMEOUT: u32 = 30; +pub const DEFAULT_SESSION_RESUMPTION: bool = false; + +/// Main point where connect external crate +/// +/// Provides high-level API for session-safe interaction with +/// [Gemini](https://geminiprotocol.net) socket server +pub struct Client { + is_session_resumption: bool, + pub socket: SocketClient, +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + // Init new socket + let socket = SocketClient::new(); + + // Setup initial configuration for Gemini Protocol + socket.set_protocol(SocketProtocol::Tcp); + socket.set_timeout(DEFAULT_TIMEOUT); + + // Done + Self { + is_session_resumption: DEFAULT_SESSION_RESUMPTION, + socket, + } + } + + // Actions + + /// Make new async request to given [Uri](https://docs.gtk.org/glib/struct.Uri.html), + /// callback with new `Response`on success or `Error` on failure + /// * compatible with user (certificate) and guest (certificate-less) connection types + pub fn request_async( + &self, + request: Request, + priority: Priority, + cancellable: Cancellable, + client_certificate: Option, + server_certificates: Option>, + callback: impl FnOnce(Result<(Response, Connection), Error>) + 'static, + ) { + // Begin new connection + // * [NetworkAddress](https://docs.gtk.org/gio/class.NetworkAddress.html) required for valid + // [SNI](https://geminiprotocol.net/docs/protocol-specification.gmi#server-name-indication) + match request.to_network_address(crate::DEFAULT_PORT) { + Ok(network_address) => { + self.socket + .connect_async(&network_address.clone(), Some(&cancellable.clone()), { + let is_session_resumption = self.is_session_resumption; + move |result| match result { + Ok(socket_connection) => { + match Connection::build( + socket_connection.clone(), + network_address, + client_certificate, + server_certificates, + is_session_resumption, + ) { + Ok(connection) => connection.clone().request_async( + request, + priority, + cancellable, + move |result| { + callback(match result { + Ok(response) => Ok(response), + Err(e) => Err(Error::Request(connection, e)), + }) + }, + ), + Err(e) => { + callback(Err(Error::Connection(socket_connection, e))) + } + } + } + Err(e) => callback(Err(Error::Connect(network_address, e))), + } + }) + } + Err(e) => callback(Err(Error::NetworkAddress(e))), + } + } + + // Setters + + /// Change glib-networking `session-resumption-enabled` property (`false` by default) + /// * [Gemini specification](https://geminiprotocol.net/docs/protocol-specification.gmi#client-certificates) + /// * [GnuTLS manual](https://www.gnutls.org/manual/html_node/Session-resumption.html) + pub fn set_session_resumption(&mut self, is_enabled: bool) { + self.is_session_resumption = is_enabled + } +} diff --git a/src/client/connection.rs b/src/client/connection.rs new file mode 100644 index 0000000..6be90f1 --- /dev/null +++ b/src/client/connection.rs @@ -0,0 +1,172 @@ +pub mod error; +pub mod request; +pub mod response; + +pub use error::Error; +pub use request::{Mode, Request}; +pub use response::Response; + +use gio::{ + Cancellable, IOStream, NetworkAddress, SocketConnection, TlsCertificate, TlsClientConnection, + prelude::{IOStreamExt, OutputStreamExtManual, TlsCertificateExt, TlsConnectionExt}, +}; +use glib::{ + Bytes, Priority, + object::{Cast, ObjectExt}, +}; + +#[derive(Debug, Clone)] +pub struct Connection { + pub network_address: NetworkAddress, + pub socket_connection: SocketConnection, + pub tls_client_connection: TlsClientConnection, +} + +impl Connection { + // Constructors + + /// Create new `Self` + pub fn build( + socket_connection: SocketConnection, + network_address: NetworkAddress, + client_certificate: Option, + server_certificates: Option>, + is_session_resumption: bool, + ) -> Result { + Ok(Self { + tls_client_connection: match new_tls_client_connection( + &socket_connection, + Some(&network_address), + server_certificates, + is_session_resumption, + ) { + Ok(tls_client_connection) => { + if let Some(ref c) = client_certificate { + tls_client_connection.set_certificate(c); + } + tls_client_connection + } + Err(e) => return Err(e), + }, + network_address, + socket_connection, + }) + } + + // Actions + + /// Send new `Request` to `Self` connection using + /// [Gemini](https://geminiprotocol.net/docs/protocol-specification.gmi) or + /// [Titan](gemini://transjovian.org/titan/page/The%20Titan%20Specification) protocol + pub fn request_async( + self, + request: Request, + priority: Priority, + cancellable: Cancellable, + callback: impl FnOnce(Result<(Response, Self), Error>) + 'static, + ) { + let output_stream = self.stream().output_stream(); + // Make sure **all header bytes** sent to the destination + // > A partial write is performed with the size of a message block, which is 16kB + // > https://docs.openssl.org/3.0/man3/SSL_write/#notes + output_stream.clone().write_all_async( + Bytes::from_owned(request.header()), + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok(_) => match request { + Request::Gemini { mode, .. } => match mode { + Mode::HeaderOnly => Response::header_from_connection_async( + self, + priority, + cancellable, + |result, connection| { + callback(match result { + Ok(response) => Ok((response, connection)), + Err(e) => Err(Error::Response(e)), + }) + }, + ), + }, + // Make sure **all data bytes** sent to the destination + // > A partial write is performed with the size of a message block, which is 16kB + // > https://docs.openssl.org/3.0/man3/SSL_write/#notes + Request::Titan { data, mode, .. } => output_stream.write_all_async( + data, + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok(_) => match mode { + Mode::HeaderOnly => Response::header_from_connection_async( + self, + priority, + cancellable, + |result, connection| { + callback(match result { + Ok(response) => Ok((response, connection)), + Err(e) => Err(Error::Response(e)), + }) + }, + ), + }, + Err((b, e)) => callback(Err(Error::Request(b, e))), + }, + ), + }, + Err((b, e)) => callback(Err(Error::Request(b, e))), + }, + ) + } + + // Getters + + /// Get [IOStream](https://docs.gtk.org/gio/class.IOStream.html) + /// * compatible with user (certificate) and guest (certificate-less) connection type + /// * useful to keep `Connection` reference active in async I/O context + pub fn stream(&self) -> IOStream { + self.tls_client_connection.clone().upcast::() + // * also `base_io_stream` method available @TODO + } +} + +// Tools + +/// Setup new [TlsClientConnection](https://docs.gtk.org/gio/iface.TlsClientConnection.html) +/// wrapper for [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) +/// using `server_identity` as the [SNI](https://geminiprotocol.net/docs/protocol-specification.gmi#server-name-indication) +fn new_tls_client_connection( + socket_connection: &SocketConnection, + server_identity: Option<&NetworkAddress>, + server_certificates: Option>, + is_session_resumption: bool, +) -> Result { + match TlsClientConnection::new(socket_connection, server_identity) { + Ok(tls_client_connection) => { + // Prevent session resumption (certificate change ability in runtime) + tls_client_connection.set_property("session-resumption-enabled", is_session_resumption); + + // Return `Err` on server connection mismatch following specification lines: + // > Gemini servers MUST use the TLS close_notify implementation to close the connection + // > A client SHOULD notify the user of such a case + // https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections + tls_client_connection.set_require_close_notify(true); + + // [TOFU](https://geminiprotocol.net/docs/protocol-specification.gmi#tls-server-certificate-validation) + tls_client_connection.connect_accept_certificate(move |_, c, _| { + server_certificates + .as_ref() + .is_none_or(|server_certificates| { + for server_certificate in server_certificates { + if server_certificate.is_same(c) { + return true; + } + } + false + }) + }); + + Ok(tls_client_connection) + } + Err(e) => Err(Error::TlsClientConnection(e)), + } +} diff --git a/src/client/connection/error.rs b/src/client/connection/error.rs new file mode 100644 index 0000000..711c2b6 --- /dev/null +++ b/src/client/connection/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Request(glib::Bytes, glib::Error), + Response(crate::client::connection::response::Error), + TlsClientConnection(glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Request(_, e) => { + write!(f, "Request error: {e}") + } + Self::Response(e) => { + write!(f, "Response error: {e}") + } + Self::TlsClientConnection(e) => { + write!(f, "TLS client connection error: {e}") + } + } + } +} diff --git a/src/client/connection/request.rs b/src/client/connection/request.rs new file mode 100644 index 0000000..f67238b --- /dev/null +++ b/src/client/connection/request.rs @@ -0,0 +1,122 @@ +pub mod error; +pub mod mode; + +pub use error::Error; +pub use mode::Mode; + +// Local dependencies + +use gio::NetworkAddress; +use glib::{Bytes, Uri, UriHideFlags}; + +/// Single `Request` implementation for different protocols +pub enum Request { + Gemini { + uri: Uri, + mode: Mode, + }, + Titan { + uri: Uri, + data: Bytes, + /// MIME type is optional attribute by Titan protocol specification, + /// but server MAY reject the request without `mime` value provided. + mime: Option, + token: Option, + mode: Mode, + }, +} + +impl Request { + // Getters + + /// Generate header string for `Self` + pub fn header(&self) -> String { + match self { + Self::Gemini { uri, .. } => format!("{uri}\r\n"), + Self::Titan { + uri, + data, + mime, + token, + .. + } => { + let mut header = format!( + "{};size={}", + uri.to_string_partial(UriHideFlags::QUERY), + data.len() + ); + if let Some(mime) = mime { + header.push_str(&format!(";mime={mime}")); + } + if let Some(token) = token { + header.push_str(&format!(";token={token}")); + } + if let Some(query) = uri.query() { + header.push_str(&format!("?{query}")); + } + header.push_str("\r\n"); + header + } + } + } + + /// Get reference to `Self` [Uri](https://docs.gtk.org/glib/struct.Uri.html) + pub fn uri(&self) -> &Uri { + match self { + Self::Gemini { uri, .. } => uri, + Self::Titan { uri, .. } => uri, + } + } + + /// Get [NetworkAddress](https://docs.gtk.org/gio/class.NetworkAddress.html) for `Self` + pub fn to_network_address(&self, default_port: u16) -> Result { + match crate::gio::network_address::from_uri(self.uri(), default_port) { + Ok(network_address) => Ok(network_address), + Err(e) => Err(Error::NetworkAddress(e)), + } + } +} + +#[test] +fn test_gemini_header() { + use glib::UriFlags; + + const REQUEST: &str = "gemini://geminiprotocol.net/"; + + assert_eq!( + Request::Gemini { + uri: Uri::parse(REQUEST, UriFlags::NONE).unwrap(), + mode: Mode::HeaderOnly + } + .header(), + format!("{REQUEST}\r\n") + ); +} + +#[test] +fn test_titan_header() { + use glib::UriFlags; + + const DATA: &[u8] = &[1, 2, 3]; + const MIME: &str = "plain/text"; + const TOKEN: &str = "token"; + + assert_eq!( + Request::Titan { + uri: Uri::parse( + "titan://geminiprotocol.net/raw/path?key=value", + UriFlags::NONE + ) + .unwrap(), + data: Bytes::from(DATA), + mime: Some(MIME.to_string()), + token: Some(TOKEN.to_string()), + mode: Mode::HeaderOnly + } + .header(), + format!( + "titan://geminiprotocol.net/raw/path;size={};mime={MIME};token={TOKEN}?key=value\r\n", + DATA.len(), + ) + ); +} diff --git a/src/client/connection/request/error.rs b/src/client/connection/request/error.rs new file mode 100644 index 0000000..5524960 --- /dev/null +++ b/src/client/connection/request/error.rs @@ -0,0 +1,16 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + NetworkAddress(crate::gio::network_address::error::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::NetworkAddress(e) => { + write!(f, "Network Address error: {e}") + } + } + } +} diff --git a/src/client/connection/request/mode.rs b/src/client/connection/request/mode.rs new file mode 100644 index 0000000..b1d8a67 --- /dev/null +++ b/src/client/connection/request/mode.rs @@ -0,0 +1,6 @@ +/// Request modes +pub enum Mode { + /// Request header bytes only, process content bytes manually + /// * useful for manual content type handle: text, stream or large content loaded by chunks + HeaderOnly, +} diff --git a/src/client/connection/response.rs b/src/client/connection/response.rs new file mode 100644 index 0000000..7dd54bb --- /dev/null +++ b/src/client/connection/response.rs @@ -0,0 +1,146 @@ +pub mod certificate; +pub mod error; +pub mod failure; +pub mod input; +pub mod redirect; +pub mod success; + +pub use certificate::Certificate; +pub use error::{Error, HeaderBytesError}; +pub use failure::Failure; +pub use input::Input; +pub use redirect::Redirect; +pub use success::Success; + +use super::Connection; +use gio::{Cancellable, IOStream}; +use glib::{Priority, object::IsA}; + +const HEADER_LEN: usize = 1024; + +/// https://geminiprotocol.net/docs/protocol-specification.gmi#responses +pub enum Response { + Input(Input), // 1* + Success(Success), // 2* + Redirect(Redirect), // 3* + Failure(Failure), // 4*,5* + Certificate(Certificate), // 6* +} + +impl Response { + /// Asynchronously create new `Self` for given `Connection` + pub fn header_from_connection_async( + connection: Connection, + priority: Priority, + cancellable: Cancellable, + callback: impl FnOnce(Result, Connection) + 'static, + ) { + header_from_stream_async( + Vec::with_capacity(HEADER_LEN), + connection.stream(), + cancellable, + priority, + |result| { + callback( + match result { + Ok(buffer) => match buffer.first() { + Some(b) => match b { + b'1' => match Input::from_utf8(&buffer) { + Ok(input) => Ok(Self::Input(input)), + Err(e) => Err(Error::Input(e)), + }, + b'2' => match Success::from_utf8(&buffer) { + Ok(success) => Ok(Self::Success(success)), + Err(e) => Err(Error::Success(e)), + }, + b'3' => match Redirect::from_utf8(&buffer) { + Ok(redirect) => Ok(Self::Redirect(redirect)), + Err(e) => Err(Error::Redirect(e)), + }, + b'4' | b'5' => match Failure::from_utf8(&buffer) { + Ok(failure) => Ok(Self::Failure(failure)), + Err(e) => Err(Error::Failure(e)), + }, + b'6' => match Certificate::from_utf8(&buffer) { + Ok(certificate) => Ok(Self::Certificate(certificate)), + Err(e) => Err(Error::Certificate(e)), + }, + b => Err(Error::Code(*b)), + }, + None => Err(Error::Protocol(buffer)), + }, + Err(e) => Err(e), + }, + connection, + ) + }, + ) + } +} + +// Tools + +/// Asynchronously read header bytes from [IOStream](https://docs.gtk.org/gio/class.IOStream.html) +/// +/// Return UTF-8 buffer collected +/// * requires `IOStream` reference to keep `Connection` active in async thread +fn header_from_stream_async( + mut buffer: Vec, + stream: impl IsA, + cancellable: Cancellable, + priority: Priority, + callback: impl FnOnce(Result, Error>) + 'static, +) { + use gio::prelude::{IOStreamExt, InputStreamExtManual}; + stream.input_stream().read_async( + vec![0], + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok((bytes, size)) => { + if size == 0 { + return callback(Ok(buffer)); + } + if buffer.len() + bytes.len() > HEADER_LEN { + buffer.extend(bytes); + return callback(Err(Error::Protocol(buffer))); + } + if bytes[0] == b'\r' { + buffer.extend(bytes); + return header_from_stream_async( + buffer, + stream, + cancellable, + priority, + callback, + ); + } + if bytes[0] == b'\n' { + buffer.extend(bytes); + return callback(Ok(buffer)); + } + buffer.extend(bytes); + header_from_stream_async(buffer, stream, cancellable, priority, callback) + } + Err((data, e)) => callback(Err(Error::Stream(e, data))), + }, + ) +} + +/// Get header bytes slice +/// * common for all child parsers +fn header_bytes(buffer: &[u8]) -> Result<&[u8], HeaderBytesError> { + for (i, b) in buffer.iter().enumerate() { + if i > 1024 { + return Err(HeaderBytesError::Len); + } + if *b == b'\r' { + let n = i + 1; + if buffer.get(n).is_some_and(|b| *b == b'\n') { + return Ok(&buffer[..n + 1]); + } + break; + } + } + Err(HeaderBytesError::End) +} diff --git a/src/client/connection/response/certificate.rs b/src/client/connection/response/certificate.rs new file mode 100644 index 0000000..07e2891 --- /dev/null +++ b/src/client/connection/response/certificate.rs @@ -0,0 +1,111 @@ +pub mod error; +pub mod not_authorized; +pub mod not_valid; +pub mod required; + +pub use error::Error; +pub use not_authorized::NotAuthorized; +pub use not_valid::NotValid; +pub use required::Required; + +const CODE: u8 = b'6'; + +/// 6* status code group +/// https://geminiprotocol.net/docs/protocol-specification.gmi#client-certificates +pub enum Certificate { + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-60 + Required(Required), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-61-certificate-not-authorized + NotAuthorized(NotAuthorized), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-62-certificate-not-valid + NotValid(NotValid), +} + +impl Certificate { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match *b { + CODE => match buffer.get(1) { + Some(b) => match *b { + b'0' => Ok(Self::Required( + Required::from_utf8(buffer).map_err(Error::Required)?, + )), + b'1' => Ok(Self::NotAuthorized( + NotAuthorized::from_utf8(buffer).map_err(Error::NotAuthorized)?, + )), + b'2' => Ok(Self::NotValid( + NotValid::from_utf8(buffer).map_err(Error::NotValid)?, + )), + b => Err(Error::SecondByte(b)), + }, + None => Err(Error::UndefinedSecondByte), + }, + b => Err(Error::FirstByte(b)), + }, + None => Err(Error::UndefinedFirstByte), + } + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty (not provided by server) + pub fn message(&self) -> Option<&str> { + match self { + Self::Required(required) => required.message(), + Self::NotAuthorized(not_authorized) => not_authorized.message(), + Self::NotValid(not_valid) => not_valid.message(), + } + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return children `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + match self { + Self::Required(required) => required.message_or_default(), + Self::NotAuthorized(not_authorized) => not_authorized.message_or_default(), + Self::NotValid(not_valid) => not_valid.message_or_default(), + } + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + match self { + Self::Required(required) => required.as_str(), + Self::NotAuthorized(not_authorized) => not_authorized.as_str(), + Self::NotValid(not_valid) => not_valid.as_str(), + } + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Required(required) => required.as_bytes(), + Self::NotAuthorized(not_authorized) => not_authorized.as_bytes(), + Self::NotValid(not_valid) => not_valid.as_bytes(), + } + } +} + +#[test] +fn test() { + fn t(source: &str, message: Option<&str>) { + let b = source.as_bytes(); + let c = Certificate::from_utf8(b).unwrap(); + assert_eq!(c.message(), message); + assert_eq!(c.as_str(), source); + assert_eq!(c.as_bytes(), b); + } + // 60 + t("60 Required\r\n", Some("Required")); + t("60\r\n", None); + // 61 + t("61 Not Authorized\r\n", Some("Not Authorized")); + t("61\r\n", None); + // 62 + t("61 Not Valid\r\n", Some("Not Valid")); + t("61\r\n", None); +} diff --git a/src/client/connection/response/certificate/error.rs b/src/client/connection/response/certificate/error.rs new file mode 100644 index 0000000..a710617 --- /dev/null +++ b/src/client/connection/response/certificate/error.rs @@ -0,0 +1,40 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + FirstByte(u8), + NotAuthorized(super::not_authorized::Error), + NotValid(super::not_valid::Error), + Required(super::required::Error), + SecondByte(u8), + UndefinedFirstByte, + UndefinedSecondByte, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::FirstByte(b) => { + write!(f, "Unexpected first byte: {b}") + } + Self::NotAuthorized(e) => { + write!(f, "NotAuthorized status parse error: {e}") + } + Self::NotValid(e) => { + write!(f, "NotValid status parse error: {e}") + } + Self::Required(e) => { + write!(f, "Required status parse error: {e}") + } + Self::SecondByte(b) => { + write!(f, "Unexpected second byte: {b}") + } + Self::UndefinedFirstByte => { + write!(f, "Undefined first byte") + } + Self::UndefinedSecondByte => { + write!(f, "Undefined second byte") + } + } + } +} diff --git a/src/client/connection/response/certificate/not_authorized.rs b/src/client/connection/response/certificate/not_authorized.rs new file mode 100644 index 0000000..b10d6ff --- /dev/null +++ b/src/client/connection/response/certificate/not_authorized.rs @@ -0,0 +1,79 @@ +pub mod error; +pub use error::Error; + +/// [Not Authorized](https://geminiprotocol.net/docs/protocol-specification.gmi#status-61) status code +pub const CODE: &[u8] = b"61"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Certificate is not authorized"; + +/// Hold header `String` for [Not Authorized](https://geminiprotocol.net/docs/protocol-specification.gmi#status-61) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct NotAuthorized(String); + +impl NotAuthorized { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty (not provided by server) + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let na = NotAuthorized::from_utf8("61 Not Authorized\r\n".as_bytes()).unwrap(); + assert_eq!(na.message(), Some("Not Authorized")); + assert_eq!(na.message_or_default(), "Not Authorized"); + assert_eq!(na.as_str(), "61 Not Authorized\r\n"); + assert_eq!(na.as_bytes(), "61 Not Authorized\r\n".as_bytes()); + + let na = NotAuthorized::from_utf8("61\r\n".as_bytes()).unwrap(); + assert_eq!(na.message(), None); + assert_eq!(na.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(na.as_str(), "61\r\n"); + assert_eq!(na.as_bytes(), "61\r\n".as_bytes()); + + // err + assert!(NotAuthorized::from_utf8("62 Fail\r\n".as_bytes()).is_err()); + assert!(NotAuthorized::from_utf8("62 Fail\r\n".as_bytes()).is_err()); + assert!(NotAuthorized::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(NotAuthorized::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/certificate/not_authorized/error.rs b/src/client/connection/response/certificate/not_authorized/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/certificate/not_authorized/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/certificate/not_valid.rs b/src/client/connection/response/certificate/not_valid.rs new file mode 100644 index 0000000..94e847c --- /dev/null +++ b/src/client/connection/response/certificate/not_valid.rs @@ -0,0 +1,79 @@ +pub mod error; +pub use error::Error; + +/// [Not Valid](https://geminiprotocol.net/docs/protocol-specification.gmi#status-62) status code +pub const CODE: &[u8] = b"62"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Certificate is not valid"; + +/// Hold header `String` for [Not Valid](https://geminiprotocol.net/docs/protocol-specification.gmi#status-62) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct NotValid(String); + +impl NotValid { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty (not provided by server) + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let nv = NotValid::from_utf8("62 Not Valid\r\n".as_bytes()).unwrap(); + assert_eq!(nv.message(), Some("Not Valid")); + assert_eq!(nv.message_or_default(), "Not Valid"); + assert_eq!(nv.as_str(), "62 Not Valid\r\n"); + assert_eq!(nv.as_bytes(), "62 Not Valid\r\n".as_bytes()); + + let nv = NotValid::from_utf8("62\r\n".as_bytes()).unwrap(); + assert_eq!(nv.message(), None); + assert_eq!(nv.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(nv.as_str(), "62\r\n"); + assert_eq!(nv.as_bytes(), "62\r\n".as_bytes()); + + // err + // @TODO assert!(NotValid::from_utf8("62Fail\r\n".as_bytes()).is_err()); + assert!(NotValid::from_utf8("63 Fail\r\n".as_bytes()).is_err()); + assert!(NotValid::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(NotValid::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/certificate/not_valid/error.rs b/src/client/connection/response/certificate/not_valid/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/certificate/not_valid/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/certificate/required.rs b/src/client/connection/response/certificate/required.rs new file mode 100644 index 0000000..b44585c --- /dev/null +++ b/src/client/connection/response/certificate/required.rs @@ -0,0 +1,79 @@ +pub mod error; +pub use error::Error; + +/// [Certificate Required](https://geminiprotocol.net/docs/protocol-specification.gmi#status-60) status code +pub const CODE: &[u8] = b"60"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Certificate required"; + +/// Hold header `String` for [Certificate Required](https://geminiprotocol.net/docs/protocol-specification.gmi#status-60) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Required(String); + +impl Required { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty (not provided by server) + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let r = Required::from_utf8("60 Required\r\n".as_bytes()).unwrap(); + assert_eq!(r.message(), Some("Required")); + assert_eq!(r.message_or_default(), "Required"); + assert_eq!(r.as_str(), "60 Required\r\n"); + assert_eq!(r.as_bytes(), "60 Required\r\n".as_bytes()); + + let r = Required::from_utf8("60\r\n".as_bytes()).unwrap(); + assert_eq!(r.message(), None); + assert_eq!(r.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(r.as_str(), "60\r\n"); + assert_eq!(r.as_bytes(), "60\r\n".as_bytes()); + + // err + assert!(Required::from_utf8("62 Fail\r\n".as_bytes()).is_err()); + assert!(Required::from_utf8("62 Fail\r\n".as_bytes()).is_err()); + assert!(Required::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Required::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/certificate/required/error.rs b/src/client/connection/response/certificate/required/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/certificate/required/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/error.rs b/src/client/connection/response/error.rs new file mode 100644 index 0000000..022ed62 --- /dev/null +++ b/src/client/connection/response/error.rs @@ -0,0 +1,70 @@ +use std::{ + fmt::{Display, Formatter, Result}, + str::Utf8Error, +}; + +#[derive(Debug)] +pub enum Error { + Certificate(super::certificate::Error), + Code(u8), + Failure(super::failure::Error), + Input(super::input::Error), + Protocol(Vec), + Redirect(super::redirect::Error), + Stream(glib::Error, Vec), + Success(super::success::Error), + Utf8Error(Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Certificate(e) => { + write!(f, "Certificate error: {e}") + } + Self::Code(b) => { + write!(f, "Unexpected status code byte: {b}") + } + Self::Failure(e) => { + write!(f, "Failure error: {e}") + } + Self::Input(e) => { + write!(f, "Input error: {e}") + } + Self::Protocol(..) => { + write!(f, "Protocol error") + } + Self::Redirect(e) => { + write!(f, "Redirect error: {e}") + } + Self::Stream(e, ..) => { + write!(f, "I/O stream error: {e}") + } + Self::Success(e) => { + write!(f, "Success error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} + +#[derive(Debug)] +pub enum HeaderBytesError { + Len, + End, +} + +impl Display for HeaderBytesError { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Len => { + write!(f, "Unexpected header length") + } + Self::End => { + write!(f, "Unexpected header end") + } + } + } +} diff --git a/src/client/connection/response/failure.rs b/src/client/connection/response/failure.rs new file mode 100644 index 0000000..1ace0ed --- /dev/null +++ b/src/client/connection/response/failure.rs @@ -0,0 +1,89 @@ +pub mod error; +pub mod permanent; +pub mod temporary; + +pub use error::Error; +pub use permanent::Permanent; +pub use temporary::Temporary; + +pub enum Failure { + /// 4* status code group + /// https://geminiprotocol.net/docs/protocol-specification.gmi#temporary-failure + Temporary(Temporary), + /// 5* status code group + /// https://geminiprotocol.net/docs/protocol-specification.gmi#permanent-failure + Permanent(Permanent), +} + +impl Failure { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match b { + b'4' => match Temporary::from_utf8(buffer) { + Ok(input) => Ok(Self::Temporary(input)), + Err(e) => Err(Error::Temporary(e)), + }, + b'5' => match Permanent::from_utf8(buffer) { + Ok(failure) => Ok(Self::Permanent(failure)), + Err(e) => Err(Error::Permanent(e)), + }, + b => Err(Error::Code(*b)), + }, + None => Err(Error::Protocol), + } + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + match self { + Self::Permanent(permanent) => permanent.message(), + Self::Temporary(temporary) => temporary.message(), + } + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return children `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + match self { + Self::Permanent(permanent) => permanent.message_or_default(), + Self::Temporary(temporary) => temporary.message_or_default(), + } + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + match self { + Self::Permanent(permanent) => permanent.as_str(), + Self::Temporary(temporary) => temporary.as_str(), + } + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Permanent(permanent) => permanent.as_bytes(), + Self::Temporary(temporary) => temporary.as_bytes(), + } + } +} + +#[test] +fn test() { + fn t(source: String, message: Option<&str>) { + let b = source.as_bytes(); + let i = Failure::from_utf8(b).unwrap(); + assert_eq!(i.message(), message); + assert_eq!(i.as_str(), source); + assert_eq!(i.as_bytes(), b); + } + for code in [40, 41, 42, 43, 44, 50, 51, 52, 53, 59] { + t(format!("{code} Message\r\n"), Some("Message")); + t(format!("{code}\r\n"), None); + } +} diff --git a/src/client/connection/response/failure/error.rs b/src/client/connection/response/failure/error.rs new file mode 100644 index 0000000..056f714 --- /dev/null +++ b/src/client/connection/response/failure/error.rs @@ -0,0 +1,28 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code(u8), + Permanent(super::permanent::Error), + Protocol, + Temporary(super::temporary::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code(b) => { + write!(f, "Unexpected status code byte: {b}") + } + Self::Permanent(e) => { + write!(f, "Permanent failure group error: {e}") + } + Self::Protocol => { + write!(f, "Protocol error") + } + Self::Temporary(e) => { + write!(f, "Temporary failure group error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent.rs b/src/client/connection/response/failure/permanent.rs new file mode 100644 index 0000000..526a208 --- /dev/null +++ b/src/client/connection/response/failure/permanent.rs @@ -0,0 +1,126 @@ +pub mod bad_request; +pub mod default; +pub mod error; +pub mod gone; +pub mod not_found; +pub mod proxy_request_refused; + +pub use bad_request::BadRequest; +pub use default::Default; +pub use error::Error; +pub use gone::Gone; +pub use not_found::NotFound; +pub use proxy_request_refused::ProxyRequestRefused; + +const CODE: u8 = b'5'; + +/// https://geminiprotocol.net/docs/protocol-specification.gmi#permanent-failure +pub enum Permanent { + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-50 + Default(Default), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-51-not-found + NotFound(NotFound), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-52-gone + Gone(Gone), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-53-proxy-request-refused + ProxyRequestRefused(ProxyRequestRefused), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-59-bad-request + BadRequest(BadRequest), +} + +impl Permanent { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match *b { + CODE => match buffer.get(1) { + Some(b) => match *b { + b'0' => Ok(Self::Default( + Default::from_utf8(buffer).map_err(Error::Default)?, + )), + b'1' => Ok(Self::NotFound( + NotFound::from_utf8(buffer).map_err(Error::NotFound)?, + )), + b'2' => Ok(Self::Gone(Gone::from_utf8(buffer).map_err(Error::Gone)?)), + b'3' => Ok(Self::ProxyRequestRefused( + ProxyRequestRefused::from_utf8(buffer) + .map_err(Error::ProxyRequestRefused)?, + )), + b'9' => Ok(Self::BadRequest( + BadRequest::from_utf8(buffer).map_err(Error::BadRequest)?, + )), + b => Err(Error::SecondByte(b)), + }, + None => Err(Error::UndefinedSecondByte), + }, + b => Err(Error::FirstByte(b)), + }, + None => Err(Error::UndefinedFirstByte), + } + } + + // Getters + + pub fn message(&self) -> Option<&str> { + match self { + Self::Default(default) => default.message(), + Self::NotFound(not_found) => not_found.message(), + Self::Gone(gone) => gone.message(), + Self::ProxyRequestRefused(proxy_request_refused) => proxy_request_refused.message(), + Self::BadRequest(bad_request) => bad_request.message(), + } + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return children `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + match self { + Self::Default(default) => default.message_or_default(), + Self::NotFound(not_found) => not_found.message_or_default(), + Self::Gone(gone) => gone.message_or_default(), + Self::ProxyRequestRefused(proxy_request_refused) => { + proxy_request_refused.message_or_default() + } + Self::BadRequest(bad_request) => bad_request.message_or_default(), + } + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + match self { + Self::Default(default) => default.as_str(), + Self::NotFound(not_found) => not_found.as_str(), + Self::Gone(gone) => gone.as_str(), + Self::ProxyRequestRefused(proxy_request_refused) => proxy_request_refused.as_str(), + Self::BadRequest(bad_request) => bad_request.as_str(), + } + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Default(default) => default.as_bytes(), + Self::NotFound(not_found) => not_found.as_bytes(), + Self::Gone(gone) => gone.as_bytes(), + Self::ProxyRequestRefused(proxy_request_refused) => proxy_request_refused.as_bytes(), + Self::BadRequest(bad_request) => bad_request.as_bytes(), + } + } +} + +#[test] +fn test() { + fn t(source: String, message: Option<&str>) { + let b = source.as_bytes(); + let i = Permanent::from_utf8(b).unwrap(); + assert_eq!(i.message(), message); + assert_eq!(i.as_str(), source); + assert_eq!(i.as_bytes(), b); + } + for code in [50, 51, 52, 53, 59] { + t(format!("{code} Message\r\n"), Some("Message")); + t(format!("{code}\r\n"), None); + } +} diff --git a/src/client/connection/response/failure/permanent/bad_request.rs b/src/client/connection/response/failure/permanent/bad_request.rs new file mode 100644 index 0000000..8cfa6f9 --- /dev/null +++ b/src/client/connection/response/failure/permanent/bad_request.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Bad Request](https://geminiprotocol.net/docs/protocol-specification.gmi#status-59-bad-request) error status code +pub const CODE: &[u8] = b"59"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Bad request"; + +/// Hold header `String` for [Bad Request](https://geminiprotocol.net/docs/protocol-specification.gmi#status-59-bad-request) error status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct BadRequest(String); + +impl BadRequest { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let br = BadRequest::from_utf8("59 Message\r\n".as_bytes()).unwrap(); + assert_eq!(br.message(), Some("Message")); + assert_eq!(br.message_or_default(), "Message"); + assert_eq!(br.as_str(), "59 Message\r\n"); + assert_eq!(br.as_bytes(), "59 Message\r\n".as_bytes()); + + let br = BadRequest::from_utf8("59\r\n".as_bytes()).unwrap(); + assert_eq!(br.message(), None); + assert_eq!(br.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(br.as_str(), "59\r\n"); + assert_eq!(br.as_bytes(), "59\r\n".as_bytes()); + + // err + assert!(BadRequest::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(BadRequest::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(BadRequest::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/permanent/bad_request/error.rs b/src/client/connection/response/failure/permanent/bad_request/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/permanent/bad_request/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent/default.rs b/src/client/connection/response/failure/permanent/default.rs new file mode 100644 index 0000000..466333d --- /dev/null +++ b/src/client/connection/response/failure/permanent/default.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Unspecified Permanent Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-50) status code +pub const CODE: &[u8] = b"50"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Permanent error"; + +/// Hold header `String` for [Unspecified Permanent Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-50) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Default(String); + +impl Default { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let d = Default::from_utf8("50 Message\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), Some("Message")); + assert_eq!(d.message_or_default(), "Message"); + assert_eq!(d.as_str(), "50 Message\r\n"); + assert_eq!(d.as_bytes(), "50 Message\r\n".as_bytes()); + + let d = Default::from_utf8("50\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), None); + assert_eq!(d.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(d.as_str(), "50\r\n"); + assert_eq!(d.as_bytes(), "50\r\n".as_bytes()); + + // err + assert!(Default::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/permanent/default/error.rs b/src/client/connection/response/failure/permanent/default/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/permanent/default/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent/error.rs b/src/client/connection/response/failure/permanent/error.rs new file mode 100644 index 0000000..df334f5 --- /dev/null +++ b/src/client/connection/response/failure/permanent/error.rs @@ -0,0 +1,48 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + BadRequest(super::bad_request::Error), + Default(super::default::Error), + FirstByte(u8), + Gone(super::gone::Error), + NotFound(super::not_found::Error), + ProxyRequestRefused(super::proxy_request_refused::Error), + SecondByte(u8), + UndefinedFirstByte, + UndefinedSecondByte, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::BadRequest(e) => { + write!(f, "BadRequest parse error: {e}") + } + Self::Default(e) => { + write!(f, "Default parse error: {e}") + } + Self::FirstByte(b) => { + write!(f, "Unexpected first byte: {b}") + } + Self::Gone(e) => { + write!(f, "Gone parse error: {e}") + } + Self::NotFound(e) => { + write!(f, "NotFound parse error: {e}") + } + Self::ProxyRequestRefused(e) => { + write!(f, "ProxyRequestRefused parse error: {e}") + } + Self::SecondByte(b) => { + write!(f, "Unexpected second byte: {b}") + } + Self::UndefinedFirstByte => { + write!(f, "Undefined first byte") + } + Self::UndefinedSecondByte => { + write!(f, "Undefined second byte") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent/gone.rs b/src/client/connection/response/failure/permanent/gone.rs new file mode 100644 index 0000000..f93d068 --- /dev/null +++ b/src/client/connection/response/failure/permanent/gone.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Server Gone Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-52-gone) status code +pub const CODE: &[u8] = b"52"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Resource gone"; + +/// Hold header `String` for [Server Gone Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-52-gone) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Gone(String); + +impl Gone { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let g = Gone::from_utf8("52 Message\r\n".as_bytes()).unwrap(); + assert_eq!(g.message(), Some("Message")); + assert_eq!(g.message_or_default(), "Message"); + assert_eq!(g.as_str(), "52 Message\r\n"); + assert_eq!(g.as_bytes(), "52 Message\r\n".as_bytes()); + + let g = Gone::from_utf8("52\r\n".as_bytes()).unwrap(); + assert_eq!(g.message(), None); + assert_eq!(g.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(g.as_str(), "52\r\n"); + assert_eq!(g.as_bytes(), "52\r\n".as_bytes()); + + // err + assert!(Gone::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(Gone::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Gone::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/permanent/gone/error.rs b/src/client/connection/response/failure/permanent/gone/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/permanent/gone/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent/not_found.rs b/src/client/connection/response/failure/permanent/not_found.rs new file mode 100644 index 0000000..d5ddca9 --- /dev/null +++ b/src/client/connection/response/failure/permanent/not_found.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Not Found Permanent Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-51-not-found) status code +pub const CODE: &[u8] = b"51"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Not Found"; + +/// Hold header `String` for [Not Found Permanent Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-51-not-found) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct NotFound(String); + +impl NotFound { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let nf = NotFound::from_utf8("51 Message\r\n".as_bytes()).unwrap(); + assert_eq!(nf.message(), Some("Message")); + assert_eq!(nf.message_or_default(), "Message"); + assert_eq!(nf.as_str(), "51 Message\r\n"); + assert_eq!(nf.as_bytes(), "51 Message\r\n".as_bytes()); + + let nf = NotFound::from_utf8("51\r\n".as_bytes()).unwrap(); + assert_eq!(nf.message(), None); + assert_eq!(nf.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(nf.as_str(), "51\r\n"); + assert_eq!(nf.as_bytes(), "51\r\n".as_bytes()); + + // err + assert!(NotFound::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(NotFound::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(NotFound::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/permanent/not_found/error.rs b/src/client/connection/response/failure/permanent/not_found/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/permanent/not_found/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/permanent/proxy_request_refused.rs b/src/client/connection/response/failure/permanent/proxy_request_refused.rs new file mode 100644 index 0000000..fba229c --- /dev/null +++ b/src/client/connection/response/failure/permanent/proxy_request_refused.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Proxy Request Refused](https://geminiprotocol.net/docs/protocol-specification.gmi#status-53-proxy-request-refused) permanent error status code +pub const CODE: &[u8] = b"53"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Proxy request refused"; + +/// Hold header `String` for [Proxy Request Refused](https://geminiprotocol.net/docs/protocol-specification.gmi#status-53-proxy-request-refused) permanent error status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct ProxyRequestRefused(String); + +impl ProxyRequestRefused { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let prf = ProxyRequestRefused::from_utf8("53 Message\r\n".as_bytes()).unwrap(); + assert_eq!(prf.message(), Some("Message")); + assert_eq!(prf.message_or_default(), "Message"); + assert_eq!(prf.as_str(), "53 Message\r\n"); + assert_eq!(prf.as_bytes(), "53 Message\r\n".as_bytes()); + + let prf = ProxyRequestRefused::from_utf8("53\r\n".as_bytes()).unwrap(); + assert_eq!(prf.message(), None); + assert_eq!(prf.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(prf.as_str(), "53\r\n"); + assert_eq!(prf.as_bytes(), "53\r\n".as_bytes()); + + // err + assert!(ProxyRequestRefused::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(ProxyRequestRefused::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(ProxyRequestRefused::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/permanent/proxy_request_refused/error.rs b/src/client/connection/response/failure/permanent/proxy_request_refused/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/permanent/proxy_request_refused/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary.rs b/src/client/connection/response/failure/temporary.rs new file mode 100644 index 0000000..cc20834 --- /dev/null +++ b/src/client/connection/response/failure/temporary.rs @@ -0,0 +1,126 @@ +pub mod cgi_error; +pub mod default; +pub mod error; +pub mod proxy_error; +pub mod server_unavailable; +pub mod slow_down; + +pub use cgi_error::CgiError; +pub use default::Default; +pub use error::Error; +pub use proxy_error::ProxyError; +pub use server_unavailable::ServerUnavailable; +pub use slow_down::SlowDown; + +const CODE: u8 = b'4'; + +/// https://geminiprotocol.net/docs/protocol-specification.gmi#temporary-failure +pub enum Temporary { + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-40 + Default(Default), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-41-server-unavailable + ServerUnavailable(ServerUnavailable), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-42-cgi-error + CgiError(CgiError), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-43-proxy-error + ProxyError(ProxyError), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-44-slow-down + SlowDown(SlowDown), +} + +impl Temporary { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match *b { + CODE => match buffer.get(1) { + Some(b) => match *b { + b'0' => Ok(Self::Default( + Default::from_utf8(buffer).map_err(Error::Default)?, + )), + b'1' => Ok(Self::ServerUnavailable( + ServerUnavailable::from_utf8(buffer) + .map_err(Error::ServerUnavailable)?, + )), + b'2' => Ok(Self::CgiError( + CgiError::from_utf8(buffer).map_err(Error::CgiError)?, + )), + b'3' => Ok(Self::ProxyError( + ProxyError::from_utf8(buffer).map_err(Error::ProxyError)?, + )), + b'4' => Ok(Self::SlowDown( + SlowDown::from_utf8(buffer).map_err(Error::SlowDown)?, + )), + b => Err(Error::SecondByte(b)), + }, + None => Err(Error::UndefinedSecondByte), + }, + b => Err(Error::FirstByte(b)), + }, + None => Err(Error::UndefinedFirstByte), + } + } + + // Getters + + pub fn message(&self) -> Option<&str> { + match self { + Self::Default(default) => default.message(), + Self::ServerUnavailable(server_unavailable) => server_unavailable.message(), + Self::CgiError(cgi_error) => cgi_error.message(), + Self::ProxyError(proxy_error) => proxy_error.message(), + Self::SlowDown(slow_down) => slow_down.message(), + } + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return children `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + match self { + Self::Default(default) => default.message_or_default(), + Self::ServerUnavailable(server_unavailable) => server_unavailable.message_or_default(), + Self::CgiError(cgi_error) => cgi_error.message_or_default(), + Self::ProxyError(proxy_error) => proxy_error.message_or_default(), + Self::SlowDown(slow_down) => slow_down.message_or_default(), + } + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + match self { + Self::Default(default) => default.as_str(), + Self::ServerUnavailable(server_unavailable) => server_unavailable.as_str(), + Self::CgiError(cgi_error) => cgi_error.as_str(), + Self::ProxyError(proxy_error) => proxy_error.as_str(), + Self::SlowDown(slow_down) => slow_down.as_str(), + } + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Default(default) => default.as_bytes(), + Self::ServerUnavailable(server_unavailable) => server_unavailable.as_bytes(), + Self::CgiError(cgi_error) => cgi_error.as_bytes(), + Self::ProxyError(proxy_error) => proxy_error.as_bytes(), + Self::SlowDown(slow_down) => slow_down.as_bytes(), + } + } +} + +#[test] +fn test() { + fn t(source: String, message: Option<&str>) { + let b = source.as_bytes(); + let i = Temporary::from_utf8(b).unwrap(); + assert_eq!(i.message(), message); + assert_eq!(i.as_str(), source); + assert_eq!(i.as_bytes(), b); + } + for code in [40, 41, 42, 43, 44] { + t(format!("{code} Message\r\n"), Some("Message")); + t(format!("{code}\r\n"), None); + } +} diff --git a/src/client/connection/response/failure/temporary/cgi_error.rs b/src/client/connection/response/failure/temporary/cgi_error.rs new file mode 100644 index 0000000..8843fa9 --- /dev/null +++ b/src/client/connection/response/failure/temporary/cgi_error.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [CGI Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-42-cgi-error) status code +pub const CODE: &[u8] = b"42"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "CGI Error"; + +/// Hold header `String` for [CGI Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-42-cgi-error) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct CgiError(String); + +impl CgiError { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let ce = CgiError::from_utf8("42 Message\r\n".as_bytes()).unwrap(); + assert_eq!(ce.message(), Some("Message")); + assert_eq!(ce.message_or_default(), "Message"); + assert_eq!(ce.as_str(), "42 Message\r\n"); + assert_eq!(ce.as_bytes(), "42 Message\r\n".as_bytes()); + + let ce = CgiError::from_utf8("42\r\n".as_bytes()).unwrap(); + assert_eq!(ce.message(), None); + assert_eq!(ce.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(ce.as_str(), "42\r\n"); + assert_eq!(ce.as_bytes(), "42\r\n".as_bytes()); + + // err + assert!(CgiError::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(CgiError::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(CgiError::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/temporary/cgi_error/error.rs b/src/client/connection/response/failure/temporary/cgi_error/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/temporary/cgi_error/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary/default.rs b/src/client/connection/response/failure/temporary/default.rs new file mode 100644 index 0000000..e56d90b --- /dev/null +++ b/src/client/connection/response/failure/temporary/default.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Unspecified Temporary Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-40) status code +pub const CODE: &[u8] = b"40"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Temporary error"; + +/// Hold header `String` for [Unspecified Temporary Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-40) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Default(String); + +impl Default { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let d = Default::from_utf8("40 Message\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), Some("Message")); + assert_eq!(d.message_or_default(), "Message"); + assert_eq!(d.as_str(), "40 Message\r\n"); + assert_eq!(d.as_bytes(), "40 Message\r\n".as_bytes()); + + let d = Default::from_utf8("40\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), None); + assert_eq!(d.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(d.as_str(), "40\r\n"); + assert_eq!(d.as_bytes(), "40\r\n".as_bytes()); + + // err + assert!(Default::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/temporary/default/error.rs b/src/client/connection/response/failure/temporary/default/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/temporary/default/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary/error.rs b/src/client/connection/response/failure/temporary/error.rs new file mode 100644 index 0000000..afa9154 --- /dev/null +++ b/src/client/connection/response/failure/temporary/error.rs @@ -0,0 +1,48 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + CgiError(super::cgi_error::Error), + Default(super::default::Error), + FirstByte(u8), + ProxyError(super::proxy_error::Error), + SecondByte(u8), + ServerUnavailable(super::server_unavailable::Error), + SlowDown(super::slow_down::Error), + UndefinedFirstByte, + UndefinedSecondByte, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::CgiError(e) => { + write!(f, "CgiError parse error: {e}") + } + Self::Default(e) => { + write!(f, "Default parse error: {e}") + } + Self::FirstByte(b) => { + write!(f, "Unexpected first byte: {b}") + } + Self::ProxyError(e) => { + write!(f, "ProxyError parse error: {e}") + } + Self::SecondByte(b) => { + write!(f, "Unexpected second byte: {b}") + } + Self::ServerUnavailable(e) => { + write!(f, "ServerUnavailable parse error: {e}") + } + Self::SlowDown(e) => { + write!(f, "SlowDown parse error: {e}") + } + Self::UndefinedFirstByte => { + write!(f, "Undefined first byte") + } + Self::UndefinedSecondByte => { + write!(f, "Undefined second byte") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary/proxy_error.rs b/src/client/connection/response/failure/temporary/proxy_error.rs new file mode 100644 index 0000000..1264c34 --- /dev/null +++ b/src/client/connection/response/failure/temporary/proxy_error.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Proxy Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-43-proxy-error) status code +pub const CODE: &[u8] = b"43"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Proxy error"; + +/// Hold header `String` for [Proxy Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-43-proxy-error) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct ProxyError(String); + +impl ProxyError { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let pe = ProxyError::from_utf8("43 Message\r\n".as_bytes()).unwrap(); + assert_eq!(pe.message(), Some("Message")); + assert_eq!(pe.message_or_default(), "Message"); + assert_eq!(pe.as_str(), "43 Message\r\n"); + assert_eq!(pe.as_bytes(), "43 Message\r\n".as_bytes()); + + let pe = ProxyError::from_utf8("43\r\n".as_bytes()).unwrap(); + assert_eq!(pe.message(), None); + assert_eq!(pe.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(pe.as_str(), "43\r\n"); + assert_eq!(pe.as_bytes(), "43\r\n".as_bytes()); + + // err + assert!(ProxyError::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(ProxyError::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(ProxyError::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/temporary/proxy_error/error.rs b/src/client/connection/response/failure/temporary/proxy_error/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/temporary/proxy_error/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary/server_unavailable.rs b/src/client/connection/response/failure/temporary/server_unavailable.rs new file mode 100644 index 0000000..f42802e --- /dev/null +++ b/src/client/connection/response/failure/temporary/server_unavailable.rs @@ -0,0 +1,81 @@ +pub mod error; +pub use error::Error; + +/// [Server Unavailable](https://geminiprotocol.net/docs/protocol-specification.gmi#status-41-server-unavailable) +/// temporary error status code +pub const CODE: &[u8] = b"41"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Server unavailable"; + +/// Hold header `String` for [Server Unavailable](https://geminiprotocol.net/docs/protocol-specification.gmi#status-41-server-unavailable) +/// temporary error status code +/// +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct ServerUnavailable(String); + +impl ServerUnavailable { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let su = ServerUnavailable::from_utf8("41 Message\r\n".as_bytes()).unwrap(); + assert_eq!(su.message(), Some("Message")); + assert_eq!(su.message_or_default(), "Message"); + assert_eq!(su.as_str(), "41 Message\r\n"); + assert_eq!(su.as_bytes(), "41 Message\r\n".as_bytes()); + + let su = ServerUnavailable::from_utf8("41\r\n".as_bytes()).unwrap(); + assert_eq!(su.message(), None); + assert_eq!(su.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(su.as_str(), "41\r\n"); + assert_eq!(su.as_bytes(), "41\r\n".as_bytes()); + + // err + assert!(ServerUnavailable::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(ServerUnavailable::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(ServerUnavailable::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/temporary/server_unavailable/error.rs b/src/client/connection/response/failure/temporary/server_unavailable/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/temporary/server_unavailable/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/failure/temporary/slow_down.rs b/src/client/connection/response/failure/temporary/slow_down.rs new file mode 100644 index 0000000..3ca346d --- /dev/null +++ b/src/client/connection/response/failure/temporary/slow_down.rs @@ -0,0 +1,81 @@ +pub mod error; +pub use error::Error; + +/// [Slow Down](https://geminiprotocol.net/docs/protocol-specification.gmi#status-44-slow-down) +/// temporary error status code +pub const CODE: &[u8] = b"44"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Slow down"; + +/// Hold header `String` for [Unspecified Temporary Error](https://geminiprotocol.net/docs/protocol-specification.gmi#status-44-slow-down) +/// temporary error status code +/// +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct SlowDown(String); + +impl SlowDown { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let sd = SlowDown::from_utf8("44 Message\r\n".as_bytes()).unwrap(); + assert_eq!(sd.message(), Some("Message")); + assert_eq!(sd.message_or_default(), "Message"); + assert_eq!(sd.as_str(), "44 Message\r\n"); + assert_eq!(sd.as_bytes(), "44 Message\r\n".as_bytes()); + + let sd = SlowDown::from_utf8("44\r\n".as_bytes()).unwrap(); + assert_eq!(sd.message(), None); + assert_eq!(sd.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(sd.as_str(), "44\r\n"); + assert_eq!(sd.as_bytes(), "44\r\n".as_bytes()); + + // err + assert!(SlowDown::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(SlowDown::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(SlowDown::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/failure/temporary/slow_down/error.rs b/src/client/connection/response/failure/temporary/slow_down/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/failure/temporary/slow_down/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/input.rs b/src/client/connection/response/input.rs new file mode 100644 index 0000000..0cd9857 --- /dev/null +++ b/src/client/connection/response/input.rs @@ -0,0 +1,92 @@ +pub mod default; +pub mod error; +pub mod sensitive; + +pub use default::Default; +pub use error::Error; +pub use sensitive::Sensitive; + +const CODE: u8 = b'1'; + +/// [Input expected](https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected) +pub enum Input { + Default(Default), + Sensitive(Sensitive), +} + +impl Input { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match *b { + CODE => match buffer.get(1) { + Some(b) => match *b { + b'0' => Ok(Self::Default( + Default::from_utf8(buffer).map_err(Error::Default)?, + )), + b'1' => Ok(Self::Sensitive( + Sensitive::from_utf8(buffer).map_err(Error::Sensitive)?, + )), + b => Err(Error::SecondByte(b)), + }, + None => Err(Error::UndefinedSecondByte), + }, + b => Err(Error::FirstByte(b)), + }, + None => Err(Error::UndefinedFirstByte), + } + } + + // Getters + + pub fn message(&self) -> Option<&str> { + match self { + Self::Default(default) => default.message(), + Self::Sensitive(sensitive) => sensitive.message(), + } + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return children `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + match self { + Self::Default(default) => default.message_or_default(), + Self::Sensitive(sensitive) => sensitive.message_or_default(), + } + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + match self { + Self::Default(default) => default.as_str(), + Self::Sensitive(sensitive) => sensitive.as_str(), + } + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Default(default) => default.as_bytes(), + Self::Sensitive(sensitive) => sensitive.as_bytes(), + } + } +} + +#[test] +fn test() { + fn t(source: &str, message: Option<&str>) { + let b = source.as_bytes(); + let i = Input::from_utf8(b).unwrap(); + assert_eq!(i.message(), message); + assert_eq!(i.as_str(), source); + assert_eq!(i.as_bytes(), b); + } + // 10 + t("10 Default\r\n", Some("Default")); + t("10\r\n", None); + // 11 + t("11 Sensitive\r\n", Some("Sensitive")); + t("11\r\n", None); +} diff --git a/src/client/connection/response/input/default.rs b/src/client/connection/response/input/default.rs new file mode 100644 index 0000000..4a5a3df --- /dev/null +++ b/src/client/connection/response/input/default.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Input Expected](https://geminiprotocol.net/docs/protocol-specification.gmi#status-10) status code +pub const CODE: &[u8] = b"10"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Input expected"; + +/// Hold header `String` for [Input Expected](https://geminiprotocol.net/docs/protocol-specification.gmi#status-10) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Default(String); + +impl Default { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let d = Default::from_utf8("10 Default\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), Some("Default")); + assert_eq!(d.message_or_default(), "Default"); + assert_eq!(d.as_str(), "10 Default\r\n"); + assert_eq!(d.as_bytes(), "10 Default\r\n".as_bytes()); + + let d = Default::from_utf8("10\r\n".as_bytes()).unwrap(); + assert_eq!(d.message(), None); + assert_eq!(d.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(d.as_str(), "10\r\n"); + assert_eq!(d.as_bytes(), "10\r\n".as_bytes()); + + // err + assert!(Default::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Default::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/input/default/error.rs b/src/client/connection/response/input/default/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/input/default/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/input/error.rs b/src/client/connection/response/input/error.rs new file mode 100644 index 0000000..6763727 --- /dev/null +++ b/src/client/connection/response/input/error.rs @@ -0,0 +1,36 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Default(super::default::Error), + FirstByte(u8), + SecondByte(u8), + Sensitive(super::sensitive::Error), + UndefinedFirstByte, + UndefinedSecondByte, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Default(e) => { + write!(f, "Default parse error: {e}") + } + Self::FirstByte(b) => { + write!(f, "Unexpected first byte: {b}") + } + Self::SecondByte(b) => { + write!(f, "Unexpected second byte: {b}") + } + Self::Sensitive(e) => { + write!(f, "Sensitive parse error: {e}") + } + Self::UndefinedFirstByte => { + write!(f, "Undefined first byte") + } + Self::UndefinedSecondByte => { + write!(f, "Undefined second byte") + } + } + } +} diff --git a/src/client/connection/response/input/sensitive.rs b/src/client/connection/response/input/sensitive.rs new file mode 100644 index 0000000..594c8fb --- /dev/null +++ b/src/client/connection/response/input/sensitive.rs @@ -0,0 +1,78 @@ +pub mod error; +pub use error::Error; + +/// [Sensitive Input](https://geminiprotocol.net/docs/protocol-specification.gmi#status-11-sensitive-input) status code +pub const CODE: &[u8] = b"11"; + +/// Default message if the optional value was not provided by the server +/// * useful to skip match cases in external applications, +/// by using `super::message_or_default` method. +pub const DEFAULT_MESSAGE: &str = "Sensitive input expected"; + +/// Hold header `String` for [Sensitive Input](https://geminiprotocol.net/docs/protocol-specification.gmi#status-11-sensitive-input) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Sensitive(String); + +impl Sensitive { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get optional message for `Self` + /// * return `None` if the message is empty + pub fn message(&self) -> Option<&str> { + self.0.get(2..).map(|s| s.trim()).filter(|x| !x.is_empty()) + } + + /// Get optional message for `Self` + /// * if the optional message not provided by the server, return `DEFAULT_MESSAGE` + pub fn message_or_default(&self) -> &str { + self.message().unwrap_or(DEFAULT_MESSAGE) + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[test] +fn test() { + // ok + let s = Sensitive::from_utf8("11 Sensitive\r\n".as_bytes()).unwrap(); + assert_eq!(s.message(), Some("Sensitive")); + assert_eq!(s.message_or_default(), "Sensitive"); + assert_eq!(s.as_str(), "11 Sensitive\r\n"); + assert_eq!(s.as_bytes(), "11 Sensitive\r\n".as_bytes()); + + let s = Sensitive::from_utf8("11\r\n".as_bytes()).unwrap(); + assert_eq!(s.message(), None); + assert_eq!(s.message_or_default(), DEFAULT_MESSAGE); + assert_eq!(s.as_str(), "11\r\n"); + assert_eq!(s.as_bytes(), "11\r\n".as_bytes()); + + // err + assert!(Sensitive::from_utf8("13 Fail\r\n".as_bytes()).is_err()); + assert!(Sensitive::from_utf8("Fail\r\n".as_bytes()).is_err()); + assert!(Sensitive::from_utf8("Fail".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/input/sensitive/error.rs b/src/client/connection/response/input/sensitive/error.rs new file mode 100644 index 0000000..4f0ee59 --- /dev/null +++ b/src/client/connection/response/input/sensitive/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/redirect.rs b/src/client/connection/response/redirect.rs new file mode 100644 index 0000000..48bd610 --- /dev/null +++ b/src/client/connection/response/redirect.rs @@ -0,0 +1,185 @@ +pub mod error; +pub mod permanent; +pub mod temporary; + +pub use error::{Error, UriError}; +pub use permanent::Permanent; +pub use temporary::Temporary; + +// Local dependencies + +use glib::{Uri, UriFlags}; + +const CODE: u8 = b'3'; + +/// [Redirection](https://geminiprotocol.net/docs/protocol-specification.gmi#redirection) statuses +pub enum Redirect { + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection + Temporary(Temporary), + /// https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection + Permanent(Permanent), +} + +impl Redirect { + // Constructors + + /// Create new `Self` from buffer include header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + match buffer.first() { + Some(b) => match *b { + CODE => match buffer.get(1) { + Some(b) => match *b { + b'0' => Ok(Self::Temporary( + Temporary::from_utf8(buffer).map_err(Error::Temporary)?, + )), + b'1' => Ok(Self::Permanent( + Permanent::from_utf8(buffer).map_err(Error::Permanent)?, + )), + b => Err(Error::SecondByte(b)), + }, + None => Err(Error::UndefinedSecondByte), + }, + b => Err(Error::FirstByte(b)), + }, + None => Err(Error::UndefinedFirstByte), + } + } + + // Getters + + pub fn target(&self) -> Result<&str, Error> { + match self { + Self::Temporary(temporary) => temporary.target().map_err(Error::Temporary), + Self::Permanent(permanent) => permanent.target().map_err(Error::Permanent), + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Temporary(temporary) => temporary.as_str(), + Self::Permanent(permanent) => permanent.as_str(), + } + } + + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Temporary(temporary) => temporary.as_bytes(), + Self::Permanent(permanent) => permanent.as_bytes(), + } + } + + pub fn uri(&self, base: &Uri) -> Result { + match self { + Self::Temporary(temporary) => temporary.uri(base).map_err(Error::Temporary), + Self::Permanent(permanent) => permanent.uri(base).map_err(Error::Permanent), + } + } +} + +// Tools + +/// Resolve [specification-compatible](https://geminiprotocol.net/docs/protocol-specification.gmi#redirection), +/// absolute [Uri](https://docs.gtk.org/glib/struct.Uri.html) for `target` using `base` +/// * fragment implementation uncompleted @TODO +fn uri(target: &str, base: &Uri) -> Result { + match Uri::build( + UriFlags::NONE, + base.scheme().as_str(), + None, // unexpected + base.host().as_deref(), + base.port(), + base.path().as_str(), + // > If a server sends a redirection in response to a request with a query string, + // > the client MUST NOT apply the query string to the new location + None, + // > A server SHOULD NOT include fragments in redirections, + // > but if one is given, and a client already has a fragment it could apply (from the original URI), + // > it is up to the client which fragment to apply. + None, // @TODO + ) + .parse_relative( + &{ + // URI started with double slash yet not supported by Glib function + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + let t = target; + match t.strip_prefix("//") { + Some(p) => { + let postfix = p.trim_start_matches(":"); + format!( + "{}://{}", + base.scheme(), + if postfix.is_empty() { + match base.host() { + Some(h) => format!("{h}/"), + None => return Err(UriError::BaseHost), + } + } else { + postfix.to_string() + } + ) + } + None => t.to_string(), + } + }, + UriFlags::NONE, + ) { + Ok(absolute) => Ok(absolute), + Err(e) => Err(UriError::ParseRelative(e)), + } +} + +#[test] +fn test() { + /// Test common assertion rules + fn t(base: &Uri, source: &str, target: &str) { + let b = source.as_bytes(); + let r = Redirect::from_utf8(b).unwrap(); + assert!(r.uri(base).is_ok_and(|u| u.to_string() == target)); + assert_eq!(r.as_str(), source); + assert_eq!(r.as_bytes(), b); + } + // common base + let base = Uri::build( + UriFlags::NONE, + "gemini", + None, + Some("geminiprotocol.net"), + -1, + "/path/", + Some("query"), + Some("fragment"), + ); + // codes test + t( + &base, + "30 gemini://geminiprotocol.net/path\r\n", + "gemini://geminiprotocol.net/path", + ); + t( + &base, + "31 gemini://geminiprotocol.net/path\r\n", + "gemini://geminiprotocol.net/path", + ); + // relative test + t( + &base, + "31 path\r\n", + "gemini://geminiprotocol.net/path/path", + ); + t( + &base, + "31 //geminiprotocol.net\r\n", + "gemini://geminiprotocol.net", + ); + t( + &base, + "31 //geminiprotocol.net/path\r\n", + "gemini://geminiprotocol.net/path", + ); + t(&base, "31 /path\r\n", "gemini://geminiprotocol.net/path"); + t(&base, "31 //:\r\n", "gemini://geminiprotocol.net/"); + t(&base, "31 //\r\n", "gemini://geminiprotocol.net/"); + t(&base, "31 /\r\n", "gemini://geminiprotocol.net/"); + t(&base, "31 ../\r\n", "gemini://geminiprotocol.net/"); + t(&base, "31 ..\r\n", "gemini://geminiprotocol.net/"); +} diff --git a/src/client/connection/response/redirect/error.rs b/src/client/connection/response/redirect/error.rs new file mode 100644 index 0000000..38aaab1 --- /dev/null +++ b/src/client/connection/response/redirect/error.rs @@ -0,0 +1,56 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + FirstByte(u8), + Permanent(super::permanent::Error), + SecondByte(u8), + Temporary(super::temporary::Error), + UndefinedFirstByte, + UndefinedSecondByte, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::FirstByte(b) => { + write!(f, "Unexpected first byte: {b}") + } + Self::Permanent(e) => { + write!(f, "Permanent parse error: {e}") + } + Self::SecondByte(b) => { + write!(f, "Unexpected second byte: {b}") + } + Self::Temporary(e) => { + write!(f, "Temporary parse error: {e}") + } + Self::UndefinedFirstByte => { + write!(f, "Undefined first byte") + } + Self::UndefinedSecondByte => { + write!(f, "Undefined second byte") + } + } + } +} + +/// Handle `super::uri` method +#[derive(Debug)] +pub enum UriError { + BaseHost, + ParseRelative(glib::Error), +} + +impl Display for UriError { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::BaseHost => { + write!(f, "URI base host required") + } + Self::ParseRelative(e) => { + write!(f, "URI parse relative error: {e}") + } + } + } +} diff --git a/src/client/connection/response/redirect/permanent.rs b/src/client/connection/response/redirect/permanent.rs new file mode 100644 index 0000000..e8e6371 --- /dev/null +++ b/src/client/connection/response/redirect/permanent.rs @@ -0,0 +1,82 @@ +pub mod error; +pub use error::Error; + +// Local dependencies + +use glib::Uri; + +/// [Permanent Redirection](https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection) status code +pub const CODE: &[u8] = b"31"; + +/// Hold header `String` for [Permanent Redirection](https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Permanent(String); + +impl Permanent { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get raw target for `Self` + /// * return `Err` if the required target is empty + pub fn target(&self) -> Result<&str, Error> { + self.0 + .get(2..) + .map(|s| s.trim()) + .filter(|x| !x.is_empty()) + .ok_or(Error::TargetEmpty) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + pub fn uri(&self, base: &Uri) -> Result { + super::uri(self.target()?, base).map_err(Error::Uri) + } +} + +#[test] +fn test() { + const BUFFER: &str = "31 gemini://geminiprotocol.net/path\r\n"; + let bytes = BUFFER.as_bytes(); + let base = Uri::build( + glib::UriFlags::NONE, + "gemini", + None, + Some("geminiprotocol.net"), + -1, + "/path/", + Some("query"), + Some("fragment"), + ); + let permanent = Permanent::from_utf8(bytes).unwrap(); + assert_eq!(permanent.as_str(), BUFFER); + assert_eq!(permanent.as_bytes(), bytes); + assert!(permanent.target().is_ok()); + assert!( + permanent + .uri(&base) + .is_ok_and(|u| u.to_string() == "gemini://geminiprotocol.net/path") + ); + assert!(Permanent::from_utf8("32 gemini://geminiprotocol.net/path\r\n".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/redirect/permanent/error.rs b/src/client/connection/response/redirect/permanent/error.rs new file mode 100644 index 0000000..0d0e81d --- /dev/null +++ b/src/client/connection/response/redirect/permanent/error.rs @@ -0,0 +1,32 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + TargetEmpty, + Uri(super::super::UriError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::TargetEmpty => { + write!(f, "Expected target is empty") + } + Self::Uri(e) => { + write!(f, "URI parse error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/redirect/temporary.rs b/src/client/connection/response/redirect/temporary.rs new file mode 100644 index 0000000..a131336 --- /dev/null +++ b/src/client/connection/response/redirect/temporary.rs @@ -0,0 +1,82 @@ +pub mod error; +pub use error::Error; + +// Local dependencies + +use glib::Uri; + +/// [Temporary Redirection](https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection) status code +pub const CODE: &[u8] = b"30"; + +/// Hold header `String` for [Temporary Redirection](https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection) status code +/// * this response type does not contain body data +/// * the header member is closed to require valid construction +pub struct Temporary(String); + +impl Temporary { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Get raw target for `Self` + /// * return `Err` if the required target is empty + pub fn target(&self) -> Result<&str, Error> { + self.0 + .get(2..) + .map(|s| s.trim()) + .filter(|x| !x.is_empty()) + .ok_or(Error::TargetEmpty) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + pub fn uri(&self, base: &Uri) -> Result { + super::uri(self.target()?, base).map_err(Error::Uri) + } +} + +#[test] +fn test() { + const BUFFER: &str = "30 gemini://geminiprotocol.net/path\r\n"; + let bytes = BUFFER.as_bytes(); + let base = Uri::build( + glib::UriFlags::NONE, + "gemini", + None, + Some("geminiprotocol.net"), + -1, + "/path/", + Some("query"), + Some("fragment"), + ); + let temporary = Temporary::from_utf8(BUFFER.as_bytes()).unwrap(); + assert_eq!(temporary.as_str(), BUFFER); + assert_eq!(temporary.as_bytes(), bytes); + assert!(temporary.target().is_ok()); + assert!( + temporary + .uri(&base) + .is_ok_and(|u| u.to_string() == "gemini://geminiprotocol.net/path") + ); + assert!(Temporary::from_utf8("32 gemini://geminiprotocol.net/path\r\n".as_bytes()).is_err()) +} diff --git a/src/client/connection/response/redirect/temporary/error.rs b/src/client/connection/response/redirect/temporary/error.rs new file mode 100644 index 0000000..0d0e81d --- /dev/null +++ b/src/client/connection/response/redirect/temporary/error.rs @@ -0,0 +1,32 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(crate::client::connection::response::HeaderBytesError), + TargetEmpty, + Uri(super::super::UriError), + Utf8Error(std::str::Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::TargetEmpty => { + write!(f, "Expected target is empty") + } + Self::Uri(e) => { + write!(f, "URI parse error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 decode error: {e}") + } + } + } +} diff --git a/src/client/connection/response/success.rs b/src/client/connection/response/success.rs new file mode 100644 index 0000000..f9493d6 --- /dev/null +++ b/src/client/connection/response/success.rs @@ -0,0 +1,76 @@ +pub mod default; +pub mod error; + +pub use default::Default; +pub use error::Error; + +const CODE: u8 = b'2'; + +pub enum Success { + Default(Default), + // reserved for 2* codes +} + +impl Success { + // Constructors + + /// Parse new `Self` from buffer bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if buffer.first().is_none_or(|b| *b != CODE) { + return Err(Error::Code); + } + match Default::from_utf8(buffer) { + Ok(default) => Ok(Self::Default(default)), + Err(e) => Err(Error::Default(e)), + } + } + + // Getters + + /// Get header bytes for `Self` type + pub fn as_header_bytes(&self) -> &[u8] { + match self { + Self::Default(default) => default.header.as_bytes(), + } + } + + /// Get header string for `Self` type + pub fn as_header_str(&self) -> &str { + match self { + Self::Default(default) => default.header.as_str(), + } + } + + /// Get parsed MIME for `Self` type + /// + /// * high-level method, useful to skip extra match case constructions; + /// * at this moment, Gemini protocol has only one status code in this scope,\ + /// this method would be deprecated in future, use on your own risk! + pub fn mime(&self) -> Result { + match self { + Self::Default(default) => default + .header + .mime() + .map_err(|e| Error::Default(default::Error::Header(e))), + } + } +} + +#[test] +fn test() { + let r = "20 text/gemini; charset=utf-8; lang=en\r\n"; + let b = r.as_bytes(); + let s = Success::from_utf8(b).unwrap(); + + match s { + Success::Default(ref d) => { + assert_eq!(d.header.mime().unwrap(), "text/gemini"); + assert!(d.content.is_empty()) + } + } + assert_eq!(s.as_header_bytes(), b); + assert_eq!(s.as_header_str(), r); + assert_eq!(s.mime().unwrap(), "text/gemini"); + + assert!(Success::from_utf8("40 text/gemini; charset=utf-8; lang=en\r\n".as_bytes()).is_err()) +} diff --git a/src/client/connection/response/success/default.rs b/src/client/connection/response/success/default.rs new file mode 100644 index 0000000..488c3e6 --- /dev/null +++ b/src/client/connection/response/success/default.rs @@ -0,0 +1,51 @@ +pub mod error; +pub mod header; + +pub use error::Error; +pub use header::Header; + +/// [Success](https://geminiprotocol.net/docs/protocol-specification.gmi#success) status code +pub const CODE: &[u8] = b"20"; + +/// Holder for [Success](https://geminiprotocol.net/docs/protocol-specification.gmi#success) status code +/// * this response type MAY contain body data +/// * the header has closed members to require valid construction +pub struct Default { + /// Formatted header holder with additional API + pub header: Header, + /// Default success response MAY include body data + /// * if the `Request` constructed with `Mode::HeaderOnly` flag,\ + /// this value wants to be processed manually, using external application logic (specific for content-type) + pub content: Vec, +} + +impl Default { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(CODE) { + return Err(Error::Code); + } + let header = Header::from_utf8(buffer).map_err(Error::Header)?; + Ok(Self { + content: buffer + .get(header.as_bytes().len()..) + .filter(|s| !s.is_empty()) + .map_or(Vec::new(), |v| v.to_vec()), + header, + }) + } +} + +#[test] +fn test() { + let d = Default::from_utf8("20 text/gemini; charset=utf-8; lang=en\r\n".as_bytes()).unwrap(); + assert_eq!(d.header.mime().unwrap(), "text/gemini"); + assert!(d.content.is_empty()); + + let d = + Default::from_utf8("20 text/gemini; charset=utf-8; lang=en\r\ndata".as_bytes()).unwrap(); + assert_eq!(d.header.mime().unwrap(), "text/gemini"); + assert_eq!(d.content.len(), 4); +} diff --git a/src/client/connection/response/success/default/error.rs b/src/client/connection/response/success/default/error.rs new file mode 100644 index 0000000..d5b28b5 --- /dev/null +++ b/src/client/connection/response/success/default/error.rs @@ -0,0 +1,20 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Header(super::header::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + } + } +} diff --git a/src/client/connection/response/success/default/header.rs b/src/client/connection/response/success/default/header.rs new file mode 100644 index 0000000..dab58b7 --- /dev/null +++ b/src/client/connection/response/success/default/header.rs @@ -0,0 +1,60 @@ +pub mod error; +pub use error::Error; + +pub struct Header(String); + +impl Header { + // Constructors + + /// Parse `Self` from buffer contains header bytes + pub fn from_utf8(buffer: &[u8]) -> Result { + if !buffer.starts_with(super::CODE) { + return Err(Error::Code); + } + Ok(Self( + std::str::from_utf8( + crate::client::connection::response::header_bytes(buffer).map_err(Error::Header)?, + ) + .map_err(Error::Utf8Error)? + .to_string(), + )) + } + + // Getters + + /// Parse content type for `Self` + pub fn mime(&self) -> Result { + glib::Regex::split_simple( + r"^\d{2}\s([^\/]+\/[^\s;]+)", + &self.0, + glib::RegexCompileFlags::DEFAULT, + glib::RegexMatchFlags::DEFAULT, + ) + .get(1) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map_or(Err(Error::Mime), |s| Ok(s.to_lowercase())) + } + + /// Get header bytes of `Self` + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + /// Get header string of `Self` + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[test] +fn test() { + let s = "20 text/gemini; charset=utf-8; lang=en\r\n"; + let b = s.as_bytes(); + let h = Header::from_utf8(b).unwrap(); + assert_eq!(h.mime().unwrap(), "text/gemini"); + assert_eq!(h.as_bytes(), b); + assert_eq!(h.as_str(), s); + + assert!(Header::from_utf8("21 text/gemini; charset=utf-8; lang=en\r\n".as_bytes()).is_err()); +} diff --git a/src/client/connection/response/success/default/header/error.rs b/src/client/connection/response/success/default/header/error.rs new file mode 100644 index 0000000..4daca3a --- /dev/null +++ b/src/client/connection/response/success/default/header/error.rs @@ -0,0 +1,31 @@ +use std::{ + fmt::{Display, Formatter, Result}, + str::Utf8Error, +}; + +#[derive(Debug)] +pub enum Error { + Code, + Mime, + Header(crate::client::connection::response::HeaderBytesError), + Utf8Error(Utf8Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Mime => { + write!(f, "Unexpected content type") + } + Self::Header(e) => { + write!(f, "Header error: {e}") + } + Self::Utf8Error(e) => { + write!(f, "UTF-8 error: {e}") + } + } + } +} diff --git a/src/client/connection/response/success/error.rs b/src/client/connection/response/success/error.rs new file mode 100644 index 0000000..fe32c5f --- /dev/null +++ b/src/client/connection/response/success/error.rs @@ -0,0 +1,20 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Code, + Default(super::default::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Code => { + write!(f, "Unexpected status code") + } + Self::Default(e) => { + write!(f, "Header error: {e}") + } + } + } +} diff --git a/src/client/error.rs b/src/client/error.rs new file mode 100644 index 0000000..73031da --- /dev/null +++ b/src/client/error.rs @@ -0,0 +1,31 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Connect(gio::NetworkAddress, glib::Error), + Connection(gio::SocketConnection, crate::client::connection::Error), + NetworkAddress(crate::client::connection::request::Error), + Request( + crate::client::connection::Connection, + crate::client::connection::Error, + ), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Connect(_, e) => { + write!(f, "Connect error: {e}") + } + Self::Connection(_, e) => { + write!(f, "Connection init error: {e}") + } + Self::NetworkAddress(e) => { + write!(f, "Network address error: {e}") + } + Self::Request(_, e) => { + write!(f, "Connection error: {e}") + } + } + } +} diff --git a/src/client/response.rs b/src/client/response.rs deleted file mode 100644 index cc98b14..0000000 --- a/src/client/response.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod body; -pub mod meta; - -pub use body::Body; -pub use meta::Meta; diff --git a/src/client/response/body.rs b/src/client/response/body.rs deleted file mode 100644 index a00eda3..0000000 --- a/src/client/response/body.rs +++ /dev/null @@ -1,200 +0,0 @@ -pub mod error; -pub use error::Error; - -use gio::{ - prelude::{IOStreamExt, InputStreamExt}, - Cancellable, SocketConnection, -}; -use glib::{Bytes, GString, Priority}; - -pub const DEFAULT_CAPACITY: usize = 0x400; -pub const DEFAULT_MAX_SIZE: usize = 0xfffff; - -/// Body container with memory-overflow-safe, dynamically allocated [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) buffer -/// -/// **Features** -/// -/// * configurable `capacity` and `max_size` options -/// * build-in [InputStream](https://docs.gtk.org/gio/class.InputStream.html) parser -/// -/// **Notice** -/// -/// * Recommended for gemtext documents -/// * For media types, use native stream processors (e.g. [Pixbuf](https://docs.gtk.org/gdk-pixbuf/ctor.Pixbuf.new_from_stream.html) for images) -pub struct Body { - buffer: Vec, - max_size: usize, -} - -impl Body { - // Constructors - - /// Create new empty `Self` with default `capacity` and `max_size` preset - pub fn new() -> Self { - Self::new_with_options(Some(DEFAULT_CAPACITY), Some(DEFAULT_MAX_SIZE)) - } - - /// Create new new `Self` with options - /// - /// Options: - /// * `capacity` initial bytes request to reduce extra memory reallocation (`DEFAULT_CAPACITY` if `None`) - /// * `max_size` max bytes to prevent memory overflow by unknown stream source (`DEFAULT_MAX_SIZE` if `None`) - pub fn new_with_options(capacity: Option, max_size: Option) -> Self { - Self { - buffer: Vec::with_capacity(match capacity { - Some(value) => value, - None => DEFAULT_CAPACITY, - }), - max_size: match max_size { - Some(value) => value, - None => DEFAULT_MAX_SIZE, - }, - } - } - - /// Simple way to create `Self` buffer from active [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) - /// - /// **Options** - /// * `socket_connection` - [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) to read bytes from - /// * `callback` function to apply on async operations complete, return `Result)>` - /// - /// **Notes** - /// - /// * method requires entire [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html), - /// not just [InputStream](https://docs.gtk.org/gio/class.InputStream.html) because of async features; - /// * use this method after `Header` bytes taken from input stream connected (otherwise, take a look on high-level `Response` parser) - pub fn from_socket_connection_async( - socket_connection: SocketConnection, - callback: impl FnOnce(Result)>) + 'static, - ) { - Self::read_all_from_socket_connection_async( - Self::new(), - socket_connection, - None, - None, - None, - callback, - ); - } - - // Actions - - /// Asynchronously read all [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) - /// from [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) to `Self.buffer` by `chunk` - /// - /// Useful to grab entire stream without risk of memory overflow (according to `Self.max_size`), - /// reduce extra memory reallocations by `capacity` option. - /// - /// **Notes** - /// - /// We are using entire [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) reference - /// instead of [InputStream](https://docs.gtk.org/gio/class.InputStream.html) just to keep main connection alive in the async chunks context - /// - /// **Options** - /// * `socket_connection` - [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) to read bytes from - /// * `cancellable` - [Cancellable](https://docs.gtk.org/gio/class.Cancellable.html) or `None::<&Cancellable>` by default - /// * `priority` - [Priority::DEFAULT](https://docs.gtk.org/glib/const.PRIORITY_DEFAULT.html) by default - /// * `chunk` optional bytes count to read per chunk (`0x100` by default) - /// * `callback` function to apply on all async operations complete, return `Result)>` - pub fn read_all_from_socket_connection_async( - mut self, - socket_connection: SocketConnection, - cancelable: Option, - priority: Option, - chunk: Option, - callback: impl FnOnce(Result)>) + 'static, - ) { - socket_connection.input_stream().read_bytes_async( - match chunk { - Some(value) => value, - None => 0x100, - }, - match priority { - Some(value) => value, - None => Priority::DEFAULT, - }, - match cancelable.clone() { - Some(value) => Some(value), - None => None::, - } - .as_ref(), - move |result| match result { - Ok(bytes) => { - // No bytes were read, end of stream - if bytes.len() == 0 { - return callback(Ok(self)); - } - - // Save chunk to buffer - if let Err(reason) = self.push(bytes) { - return callback(Err((reason, None))); - }; - - // Continue bytes read.. - self.read_all_from_socket_connection_async( - socket_connection, - cancelable, - priority, - chunk, - callback, - ); - } - Err(reason) => callback(Err((Error::InputStreamRead, Some(reason.message())))), - }, - ); - } - - /// Push [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) to `Self.buffer` - /// - /// Return `Error::Overflow` on `max_size` reached - pub fn push(&mut self, bytes: Bytes) -> Result { - // Calculate new size value - let total = self.buffer.len() + bytes.len(); - - // Validate overflow - if total > self.max_size { - return Err(Error::BufferOverflow); - } - - // Success - self.buffer.push(bytes); - - Ok(total) - } - - // Setters - - /// Set new `max_size` value, `DEFAULT_MAX_SIZE` if `None` - pub fn set_max_size(&mut self, value: Option) { - self.max_size = match value { - Some(size) => size, - None => DEFAULT_MAX_SIZE, - } - } - - // Getters - - /// Get reference to `Self.buffer` [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) collected - pub fn buffer(&self) -> &Vec { - &self.buffer - } - - /// Return copy of `Self.buffer` [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) as UTF-8 vector - pub fn to_utf8(&self) -> Vec { - self.buffer - .iter() - .flat_map(|byte| byte.iter()) - .cloned() - .collect() - } - - // Intentable getters - - /// Try convert `Self.buffer` [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) to GString - pub fn to_gstring(&self) -> Result { - match GString::from_utf8(self.to_utf8()) { - Ok(result) => Ok(result), - Err(_) => Err(Error::Decode), - } - } -} diff --git a/src/client/response/body/error.rs b/src/client/response/body/error.rs deleted file mode 100644 index b90b9c2..0000000 --- a/src/client/response/body/error.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub enum Error { - Decode, - InputStreamRead, - BufferOverflow, -} diff --git a/src/client/response/meta.rs b/src/client/response/meta.rs deleted file mode 100644 index 2143b64..0000000 --- a/src/client/response/meta.rs +++ /dev/null @@ -1,191 +0,0 @@ -pub mod data; -pub mod error; -pub mod mime; -pub mod status; - -pub use data::Data; -pub use error::Error; -pub use mime::Mime; -pub use status::Status; - -use gio::{ - prelude::{IOStreamExt, InputStreamExt}, - Cancellable, SocketConnection, -}; -use glib::{Bytes, Priority}; - -pub const MAX_LEN: usize = 0x400; // 1024 - -pub struct Meta { - data: Data, - mime: Mime, - status: Status, - // @TODO - // charset: Charset, - // language: Language, -} - -impl Meta { - // Constructors - - /// Create new `Self` from UTF-8 buffer - pub fn from_utf8(buffer: &[u8]) -> Result)> { - let len = buffer.len(); - - match buffer.get(..if len > MAX_LEN { MAX_LEN } else { len }) { - Some(slice) => { - // Parse data - let data = Data::from_utf8(&slice); - - if let Err(reason) = data { - return Err(( - match reason { - data::Error::Decode => Error::DataDecode, - data::Error::Protocol => Error::DataProtocol, - }, - None, - )); - } - - // MIME - - let mime = Mime::from_utf8(&slice); - - if let Err(reason) = mime { - return Err(( - match reason { - mime::Error::Decode => Error::MimeDecode, - mime::Error::Protocol => Error::MimeProtocol, - mime::Error::Undefined => Error::MimeUndefined, - }, - None, - )); - } - - // Status - - let status = Status::from_utf8(&slice); - - if let Err(reason) = status { - return Err(( - match reason { - status::Error::Decode => Error::StatusDecode, - status::Error::Protocol => Error::StatusProtocol, - status::Error::Undefined => Error::StatusUndefined, - }, - None, - )); - } - - Ok(Self { - data: data.unwrap(), - mime: mime.unwrap(), - status: status.unwrap(), - }) - } - None => Err((Error::Protocol, None)), - } - } - - /// Asynchronously create new `Self` from [InputStream](https://docs.gtk.org/gio/class.InputStream.html) - /// for given [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) - pub fn from_socket_connection_async( - socket_connection: SocketConnection, - priority: Option, - cancellable: Option, - on_complete: impl FnOnce(Result)>) + 'static, - ) { - read_from_socket_connection_async( - Vec::with_capacity(MAX_LEN), - socket_connection, - match cancellable { - Some(value) => Some(value), - None => None::, - }, - match priority { - Some(value) => value, - None => Priority::DEFAULT, - }, - |result| match result { - Ok(buffer) => on_complete(Self::from_utf8(&buffer)), - Err(reason) => on_complete(Err(reason)), - }, - ); - } - - // Getters - - pub fn status(&self) -> &Status { - &self.status - } - - pub fn data(&self) -> &Data { - &self.data - } - - pub fn mime(&self) -> &Mime { - &self.mime - } -} - -// Tools - -/// Asynchronously take meta bytes from [InputStream](https://docs.gtk.org/gio/class.InputStream.html) -/// for given [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) -/// -/// * this function implements low-level helper for `Meta::from_socket_connection_async`, also provides public API for external integrations -/// * requires entire `SocketConnection` instead of `InputStream` to keep connection alive in async context -pub fn read_from_socket_connection_async( - mut buffer: Vec, - connection: SocketConnection, - cancellable: Option, - priority: Priority, - on_complete: impl FnOnce(Result, (Error, Option<&str>)>) + 'static, -) { - connection.input_stream().read_bytes_async( - 1, // do not change! - priority, - cancellable.clone().as_ref(), - move |result| match result { - Ok(bytes) => { - // Expect valid header length - if bytes.len() == 0 || buffer.len() >= MAX_LEN { - return on_complete(Err((Error::Protocol, None))); - } - - // Read next byte without buffer record - if bytes.contains(&b'\r') { - return read_from_socket_connection_async( - buffer, - connection, - cancellable, - priority, - on_complete, - ); - } - - // Complete without buffer record - if bytes.contains(&b'\n') { - return on_complete(Ok(buffer - .iter() - .flat_map(|byte| byte.iter()) - .cloned() - .collect())); // convert to UTF-8 - } - - // Record - buffer.push(bytes); - - // Continue - read_from_socket_connection_async( - buffer, - connection, - cancellable, - priority, - on_complete, - ); - } - Err(reason) => on_complete(Err((Error::InputStream, Some(reason.message())))), - }, - ); -} diff --git a/src/client/response/meta/charset.rs b/src/client/response/meta/charset.rs deleted file mode 100644 index 1673a59..0000000 --- a/src/client/response/meta/charset.rs +++ /dev/null @@ -1 +0,0 @@ -// @TODO diff --git a/src/client/response/meta/data.rs b/src/client/response/meta/data.rs deleted file mode 100644 index d903f08..0000000 --- a/src/client/response/meta/data.rs +++ /dev/null @@ -1,59 +0,0 @@ -pub mod error; -pub use error::Error; - -use glib::GString; - -pub const MAX_LEN: usize = 0x400; // 1024 - -/// Meta data holder for response -/// -/// Could be created from entire response buffer or just header slice -/// -/// Use as: -/// * placeholder for 10, 11 status -/// * URL for 30, 31 status -pub struct Data { - value: Option, -} - -impl Data { - /// Parse Meta from UTF-8 - pub fn from_utf8(buffer: &[u8]) -> Result { - // Init bytes buffer - let mut bytes: Vec = Vec::with_capacity(MAX_LEN); - - // Calculate len once - let len = buffer.len(); - - // Skip 3 bytes for status code of `MAX_LEN` expected - match buffer.get(3..if len > MAX_LEN { MAX_LEN - 3 } else { len }) { - Some(slice) => { - for &byte in slice { - // End of header - if byte == b'\r' { - break; - } - - // Continue - bytes.push(byte); - } - - // Assumes the bytes are valid UTF-8 - match GString::from_utf8(bytes) { - Ok(value) => Ok(Self { - value: match value.is_empty() { - false => Some(value), - true => None, - }, - }), - Err(_) => Err(Error::Decode), - } - } - None => Err(Error::Protocol), - } - } - - pub fn value(&self) -> &Option { - &self.value - } -} diff --git a/src/client/response/meta/data/error.rs b/src/client/response/meta/data/error.rs deleted file mode 100644 index 125f9c6..0000000 --- a/src/client/response/meta/data/error.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[derive(Debug)] -pub enum Error { - Decode, - Protocol, -} diff --git a/src/client/response/meta/error.rs b/src/client/response/meta/error.rs deleted file mode 100644 index b1414eb..0000000 --- a/src/client/response/meta/error.rs +++ /dev/null @@ -1,13 +0,0 @@ -#[derive(Debug)] -pub enum Error { - DataDecode, - DataProtocol, - InputStream, - MimeDecode, - MimeProtocol, - MimeUndefined, - Protocol, - StatusDecode, - StatusProtocol, - StatusUndefined, -} diff --git a/src/client/response/meta/language.rs b/src/client/response/meta/language.rs deleted file mode 100644 index 1673a59..0000000 --- a/src/client/response/meta/language.rs +++ /dev/null @@ -1 +0,0 @@ -// @TODO diff --git a/src/client/response/meta/mime.rs b/src/client/response/meta/mime.rs deleted file mode 100644 index 82e6c14..0000000 --- a/src/client/response/meta/mime.rs +++ /dev/null @@ -1,102 +0,0 @@ -pub mod error; -pub use error::Error; - -use glib::{GString, Uri}; -use std::path::Path; - -pub const MAX_LEN: usize = 0x400; // 1024 - -/// https://geminiprotocol.net/docs/gemtext-specification.gmi#media-type-parameters -#[derive(Debug)] -pub enum Mime { - // Text - TextGemini, - TextPlain, - // Image - ImageGif, - ImageJpeg, - ImagePng, - ImageWebp, - // Audio - AudioFlac, - AudioMpeg, - AudioOgg, -} // @TODO - -impl Mime { - pub fn from_utf8(buffer: &[u8]) -> Result { - let len = buffer.len(); - match buffer.get(..if len > MAX_LEN { MAX_LEN } else { len }) { - Some(value) => match GString::from_utf8(value.into()) { - Ok(string) => Self::from_string(string.as_str()), - Err(_) => Err(Error::Decode), - }, - None => Err(Error::Protocol), - } - } - - pub fn from_path(path: &Path) -> Result { - match path.extension().and_then(|extension| extension.to_str()) { - // Text - Some("gmi" | "gemini") => Ok(Self::TextGemini), - Some("txt") => Ok(Self::TextPlain), - // Image - Some("gif") => Ok(Self::ImageGif), - Some("jpeg" | "jpg") => Ok(Self::ImageJpeg), - Some("png") => Ok(Self::ImagePng), - Some("webp") => Ok(Self::ImageWebp), - // Audio - Some("flac") => Ok(Self::AudioFlac), - Some("mp3") => Ok(Self::AudioMpeg), - Some("oga" | "ogg" | "opus" | "spx") => Ok(Self::AudioOgg), - _ => Err(Error::Undefined), - } // @TODO extension to lowercase - } - - pub fn from_string(value: &str) -> Result { - // Text - if value.contains("text/gemini") { - return Ok(Self::TextGemini); - } - - if value.contains("text/plain") { - return Ok(Self::TextPlain); - } - - // Image - if value.contains("image/gif") { - return Ok(Self::ImageGif); - } - - if value.contains("image/jpeg") { - return Ok(Self::ImageJpeg); - } - - if value.contains("image/webp") { - return Ok(Self::ImageWebp); - } - - if value.contains("image/png") { - return Ok(Self::ImagePng); - } - - // Audio - if value.contains("audio/flac") { - return Ok(Self::AudioFlac); - } - - if value.contains("audio/mpeg") { - return Ok(Self::AudioMpeg); - } - - if value.contains("audio/ogg") { - return Ok(Self::AudioOgg); - } - - Err(Error::Undefined) - } - - pub fn from_uri(uri: &Uri) -> Result { - Self::from_path(Path::new(&uri.to_string())) - } -} diff --git a/src/client/response/meta/mime/error.rs b/src/client/response/meta/mime/error.rs deleted file mode 100644 index 989e734..0000000 --- a/src/client/response/meta/mime/error.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Debug)] -pub enum Error { - Decode, - Protocol, - Undefined, -} diff --git a/src/client/response/meta/status.rs b/src/client/response/meta/status.rs deleted file mode 100644 index 2875d31..0000000 --- a/src/client/response/meta/status.rs +++ /dev/null @@ -1,40 +0,0 @@ -pub mod error; -pub use error::Error; - -use glib::GString; - -/// https://geminiprotocol.net/docs/protocol-specification.gmi#status-codes -#[derive(Debug)] -pub enum Status { - // 10 | 11 - Input, - SensitiveInput, - // 20 - Success, - // 30 | 31 - Redirect, - PermanentRedirect, -} // @TODO - -impl Status { - pub fn from_utf8(buffer: &[u8]) -> Result { - match buffer.get(0..2) { - Some(value) => match GString::from_utf8(value.to_vec()) { - Ok(string) => Self::from_string(string.as_str()), - Err(_) => Err(Error::Decode), - }, - None => Err(Error::Protocol), - } - } - - pub fn from_string(code: &str) -> Result { - match code { - "10" => Ok(Self::Input), - "11" => Ok(Self::SensitiveInput), - "20" => Ok(Self::Success), - "30" => Ok(Self::Redirect), - "31" => Ok(Self::PermanentRedirect), - _ => Err(Error::Undefined), - } - } -} diff --git a/src/client/response/meta/status/error.rs b/src/client/response/meta/status/error.rs deleted file mode 100644 index 989e734..0000000 --- a/src/client/response/meta/status/error.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Debug)] -pub enum Error { - Decode, - Protocol, - Undefined, -} diff --git a/src/gio.rs b/src/gio.rs index c20d929..72a89d8 100644 --- a/src/gio.rs +++ b/src/gio.rs @@ -1 +1,3 @@ +pub mod file_output_stream; pub mod memory_input_stream; +pub mod network_address; diff --git a/src/gio/file_output_stream.rs b/src/gio/file_output_stream.rs new file mode 100644 index 0000000..2dffb5e --- /dev/null +++ b/src/gio/file_output_stream.rs @@ -0,0 +1,69 @@ +pub mod error; +pub mod size; + +pub use error::Error; +pub use size::Size; + +use gio::{ + Cancellable, FileOutputStream, IOStream, + prelude::{IOStreamExt, InputStreamExt, OutputStreamExtManual}, +}; +use glib::{Bytes, Priority, object::IsA}; + +/// Asynchronously move all bytes from [IOStream](https://docs.gtk.org/gio/class.IOStream.html) +/// to [FileOutputStream](https://docs.gtk.org/gio/class.FileOutputStream.html) +/// * require `IOStream` reference to keep `Connection` active in async thread +pub fn from_stream_async( + io_stream: impl IsA, + file_output_stream: FileOutputStream, + cancellable: Cancellable, + priority: Priority, + mut size: Size, + (on_chunk, on_complete): ( + impl Fn(Bytes, usize) + 'static, // on_chunk + impl FnOnce(Result<(FileOutputStream, usize), Error>) + 'static, // on_complete + ), +) { + io_stream.input_stream().read_bytes_async( + size.chunk, + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok(bytes) => { + size.total += bytes.len(); + on_chunk(bytes.clone(), size.total); + + if let Some(limit) = size.limit + && size.total > limit + { + return on_complete(Err(Error::BytesTotal(size.total, limit))); + } + + if bytes.is_empty() { + return on_complete(Ok((file_output_stream, size.total))); + } + + // Make sure **all bytes** sent to the destination + // > A partial write is performed with the size of a message block, which is 16kB + // > https://docs.openssl.org/3.0/man3/SSL_write/#notes + file_output_stream.clone().write_all_async( + bytes, + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok(_) => from_stream_async( + io_stream, + file_output_stream, + cancellable, + priority, + size, + (on_chunk, on_complete), + ), + Err((b, e)) => on_complete(Err(Error::OutputStream(b, e))), + }, + ) + } + Err(e) => on_complete(Err(Error::InputStream(e))), + }, + ) +} diff --git a/src/gio/file_output_stream/error.rs b/src/gio/file_output_stream/error.rs new file mode 100644 index 0000000..6b1ee76 --- /dev/null +++ b/src/gio/file_output_stream/error.rs @@ -0,0 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + BytesTotal(usize, usize), + InputStream(glib::Error), + OutputStream(glib::Bytes, glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::BytesTotal(total, limit) => { + write!(f, "Bytes total limit reached: {total} / {limit}") + } + Self::InputStream(e) => { + write!(f, "Input stream error: {e}") + } + Self::OutputStream(_, e) => { + write!(f, "Output stream error: {e}") + } + } + } +} diff --git a/src/gio/file_output_stream/size.rs b/src/gio/file_output_stream/size.rs new file mode 100644 index 0000000..5d0c911 --- /dev/null +++ b/src/gio/file_output_stream/size.rs @@ -0,0 +1,17 @@ +/// Mutable bytes count +pub struct Size { + pub chunk: usize, + /// `None` for unlimited + pub limit: Option, + pub total: usize, +} + +impl Default for Size { + fn default() -> Self { + Self { + chunk: 0x10000, // 64KB + limit: None, + total: 0, + } + } +} diff --git a/src/gio/memory_input_stream.rs b/src/gio/memory_input_stream.rs index 1c8d193..2b1fc39 100644 --- a/src/gio/memory_input_stream.rs +++ b/src/gio/memory_input_stream.rs @@ -1,130 +1,95 @@ pub mod error; +pub mod size; + pub use error::Error; +pub use size::Size; use gio::{ + Cancellable, IOStream, MemoryInputStream, prelude::{IOStreamExt, InputStreamExt, MemoryInputStreamExt}, - Cancellable, MemoryInputStream, SocketConnection, }; -use glib::{Bytes, Priority}; +use glib::{Priority, object::IsA}; /// Asynchronously create new [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) -/// from [InputStream](https://docs.gtk.org/gio/class.InputStream.html) -/// for given [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) +/// from [IOStream](https://docs.gtk.org/gio/class.IOStream.html) /// -/// Useful to create dynamically allocated, memory-safe buffer -/// from remote connections, where final size of target data could not be known by Gemini protocol restrictions. -/// Also, could be useful for [Pixbuf](https://docs.gtk.org/gdk-pixbuf/class.Pixbuf.html) or -/// loading widgets like [Spinner](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.Spinner.html) -/// to display bytes on async data loading. -/// -/// * this function takes entire `SocketConnection` reference (not `MemoryInputStream`) just to keep connection alive in the async context -/// -/// **Implementation** -/// -/// Implements low-level `read_all_from_socket_connection_async` function: -/// * recursively read all bytes from `InputStream` for `SocketConnection` according to `bytes_in_chunk` argument -/// * calculates total bytes length on every chunk iteration, validate sum with `bytes_total_limit` argument -/// * stop reading `InputStream` with `Result` on zero bytes in chunk received -/// * applies callback functions: -/// * `on_chunk` - return reference to [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) and `bytes_total` collected for every chunk in reading loop -/// * `on_complete` - return `MemoryInputStream` on success or `Error` on failure as `Result` -pub fn from_socket_connection_async( - socket_connection: SocketConnection, - cancelable: Option, +/// **Useful for** +/// * safe read (of memory overflow) to dynamically allocated buffer, where final size of target data unknown +/// * calculate bytes processed on chunk load +pub fn from_stream_async( + io_stream: impl IsA, priority: Priority, - bytes_in_chunk: usize, - bytes_total_limit: usize, - on_chunk: impl Fn((Bytes, usize)) + 'static, - on_complete: impl FnOnce(Result)>) + 'static, + cancelable: Cancellable, + size: Size, + (on_chunk, on_complete): ( + impl Fn(usize, usize) + 'static, + impl FnOnce(Result<(MemoryInputStream, usize), Error>) + 'static, + ), ) { - read_all_from_socket_connection_async( + for_memory_input_stream_async( MemoryInputStream::new(), - socket_connection, - cancelable, + io_stream, priority, - bytes_in_chunk, - bytes_total_limit, - 0, // initial `bytes_total` value - on_chunk, - on_complete, + cancelable, + size, + (on_chunk, on_complete), ); } -/// Low-level helper for `from_socket_connection_async` function, -/// also provides public API for external usage. -/// -/// Asynchronously read [InputStream](https://docs.gtk.org/gio/class.InputStream.html) -/// from [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) -/// to given [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html). -/// -/// Useful to create dynamically allocated, memory-safe buffer -/// from remote connections, where final size of target data could not be known by Gemini protocol restrictions. -/// Also, could be useful for [Pixbuf](https://docs.gtk.org/gdk-pixbuf/class.Pixbuf.html) or -/// loading widgets like [Spinner](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.Spinner.html) -/// to display bytes on async data loading. -/// -/// * this function takes entire `SocketConnection` reference (not `MemoryInputStream`) just to keep connection alive in the async context -/// -/// **Implementation** -/// -/// * recursively read all bytes from `InputStream` for `SocketConnection` according to `bytes_in_chunk` argument -/// * calculates total bytes length on every chunk iteration, validate sum with `bytes_total_limit` argument -/// * stop reading `InputStream` with `Result` on zero bytes in chunk received, otherwise continue next chunk request in loop -/// * applies callback functions: -/// * `on_chunk` - return reference to [Bytes](https://docs.gtk.org/glib/struct.Bytes.html) and `bytes_total` collected for every chunk in reading loop -/// * `on_complete` - return `MemoryInputStream` on success or `Error` on failure as `Result` -pub fn read_all_from_socket_connection_async( +/// Asynchronously move all bytes from [IOStream](https://docs.gtk.org/gio/class.IOStream.html) +/// to [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) +/// * require `IOStream` reference to keep `Connection` active in async thread +pub fn for_memory_input_stream_async( memory_input_stream: MemoryInputStream, - socket_connection: SocketConnection, - cancelable: Option, + io_stream: impl IsA, priority: Priority, - bytes_in_chunk: usize, - bytes_total_limit: usize, - bytes_total: usize, - on_chunk: impl Fn((Bytes, usize)) + 'static, - on_complete: impl FnOnce(Result)>) + 'static, + cancellable: Cancellable, + mut size: Size, + (on_chunk, on_complete): ( + impl Fn(usize, usize) + 'static, + impl FnOnce(Result<(MemoryInputStream, usize), Error>) + 'static, + ), ) { - socket_connection.input_stream().read_bytes_async( - bytes_in_chunk, + io_stream.input_stream().read_bytes_async( + size.chunk, priority, - cancelable.clone().as_ref(), + Some(&cancellable.clone()), move |result| match result { Ok(bytes) => { - // Update bytes total - let bytes_total = bytes_total + bytes.len(); + let len = bytes.len(); // calculate once - // Callback chunk function - on_chunk((bytes.clone(), bytes_total)); - - // Validate max size - if bytes_total > bytes_total_limit { - return on_complete(Err((Error::BytesTotal, None))); + // is end of stream + if len == 0 { + return on_complete(Ok((memory_input_stream, size.total))); } - // No bytes were read, end of stream - if bytes.len() == 0 { - return on_complete(Ok(memory_input_stream)); - } + // callback chunk function + size.total += len; + on_chunk(len, size.total); - // Write chunk bytes + // push bytes into the memory pool memory_input_stream.add_bytes(&bytes); - // Continue - read_all_from_socket_connection_async( + // prevent memory overflow + if size.total > size.limit { + return on_complete(Err(Error::BytesTotal( + memory_input_stream, + size.total, + size.limit, + ))); + } + + // handle next chunk.. + for_memory_input_stream_async( memory_input_stream, - socket_connection, - cancelable, + io_stream, priority, - bytes_in_chunk, - bytes_total_limit, - bytes_total, - on_chunk, - on_complete, - ); - } - Err(reason) => { - on_complete(Err((Error::InputStream, Some(reason.message())))); + cancellable, + size, + (on_chunk, on_complete), + ) } + Err(e) => on_complete(Err(Error::InputStream(memory_input_stream, e))), }, - ); + ) } diff --git a/src/gio/memory_input_stream/error.rs b/src/gio/memory_input_stream/error.rs index b5cda40..6b8ae86 100644 --- a/src/gio/memory_input_stream/error.rs +++ b/src/gio/memory_input_stream/error.rs @@ -1,5 +1,20 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { - BytesTotal, - InputStream, + BytesTotal(gio::MemoryInputStream, usize, usize), + InputStream(gio::MemoryInputStream, glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::BytesTotal(_, total, limit) => { + write!(f, "Bytes total limit reached: {total} / {limit}") + } + Self::InputStream(_, e) => { + write!(f, "Input stream error: {e}") + } + } + } } diff --git a/src/gio/memory_input_stream/size.rs b/src/gio/memory_input_stream/size.rs new file mode 100644 index 0000000..9a10bd3 --- /dev/null +++ b/src/gio/memory_input_stream/size.rs @@ -0,0 +1,16 @@ +/// Mutable bytes count +pub struct Size { + pub chunk: usize, + pub limit: usize, + pub total: usize, +} + +impl Default for Size { + fn default() -> Self { + Self { + chunk: 0x10000, // 64KB + limit: 0xfffff, // 1 MB + total: 0, + } + } +} diff --git a/src/gio/network_address.rs b/src/gio/network_address.rs new file mode 100644 index 0000000..4869509 --- /dev/null +++ b/src/gio/network_address.rs @@ -0,0 +1,24 @@ +pub mod error; +pub use error::Error; + +use gio::NetworkAddress; +use glib::Uri; + +/// Create new valid [NetworkAddress](https://docs.gtk.org/gio/class.NetworkAddress.html) from [Uri](https://docs.gtk.org/glib/struct.Uri.html) +/// +/// Useful as: +/// * shared [SocketConnectable](https://docs.gtk.org/gio/iface.SocketConnectable.html) interface +/// * [SNI](https://geminiprotocol.net/docs/protocol-specification.gmi#server-name-indication) record for TLS connections +pub fn from_uri(uri: &Uri, default_port: u16) -> Result { + Ok(NetworkAddress::new( + &match uri.host() { + Some(host) => host, + None => return Err(Error::Host(uri.to_string())), + }, + if uri.port().is_positive() { + uri.port() as u16 + } else { + default_port + }, + )) +} diff --git a/src/gio/network_address/error.rs b/src/gio/network_address/error.rs new file mode 100644 index 0000000..762fab6 --- /dev/null +++ b/src/gio/network_address/error.rs @@ -0,0 +1,16 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Host(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Host(url) => { + write!(f, "Host required for {url}") + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 26403ca..afd549d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,18 @@ pub mod client; pub mod gio; + +// Main API + +pub use client::Client; + +// Global defaults + +pub const DEFAULT_PORT: u16 = 1965; + +// Debug + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR"); +pub const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR"); +pub const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH"); diff --git a/tests/client.rs b/tests/client.rs deleted file mode 100644 index 1673a59..0000000 --- a/tests/client.rs +++ /dev/null @@ -1 +0,0 @@ -// @TODO