diff --git a/Cargo.toml b/Cargo.toml index 512c152..e27248d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ggemini" -version = "0.10.0" +version = "0.11.0" edition = "2021" license = "MIT" readme = "README.md" diff --git a/src/client.rs b/src/client.rs index 7335b61..ed70099 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,128 @@ -//! Client API to interact Server using -//! [Gemini protocol](https://geminiprotocol.net/docs/protocol-specification.gmi) +//! High-level client API to interact with Gemini Socket Server: +//! * https://geminiprotocol.net/docs/protocol-specification.gmi +pub mod connection; +pub mod error; pub mod response; + +pub use connection::Connection; +pub use error::Error; +pub use response::Response; + +use gio::{ + prelude::{IOStreamExt, OutputStreamExt, SocketClientExt}, + Cancellable, NetworkAddress, SocketClient, SocketProtocol, TlsCertificate, +}; +use glib::{Bytes, Priority, Uri}; + +pub const DEFAULT_PORT: u16 = 1965; +pub const DEFAULT_TIMEOUT: u32 = 10; + +pub struct Client { + pub socket: SocketClient, +} + +impl Client { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + let socket = SocketClient::new(); + + socket.set_protocol(SocketProtocol::Tcp); + socket.set_timeout(DEFAULT_TIMEOUT); + + Self { socket } + } + + // Actions + + /// Make async request to given [Uri](https://docs.gtk.org/glib/struct.Uri.html), + /// callback with `Result`on success or `Error` on failure. + /// * creates new [SocketConnection](https://docs.gtk.org/gio/class.SocketConnection.html) + /// * session management by Glib TLS Backend + pub fn request_async( + &self, + uri: Uri, + priority: Option, + cancellable: Option, + certificate: Option, + callback: impl Fn(Result) + 'static, + ) { + match network_address_for(&uri) { + Ok(network_address) => { + self.socket.connect_async( + &network_address.clone(), + match cancellable { + Some(ref cancellable) => Some(cancellable.clone()), + None => None::, + } + .as_ref(), + move |result| match result { + Ok(connection) => { + match Connection::from(network_address, connection, certificate) { + Ok(result) => request_async( + result, + uri.to_string(), + match priority { + Some(priority) => priority, + None => Priority::DEFAULT, + }, + cancellable.unwrap(), // @TODO + move |result| callback(result), + ), + Err(reason) => callback(Err(Error::Connection(reason))), + } + } + Err(reason) => callback(Err(Error::Connect(reason))), + }, + ); + } + Err(reason) => callback(Err(reason)), + }; + } +} + +// Private helpers + +/// [SocketConnectable](https://docs.gtk.org/gio/iface.SocketConnectable.html) / +/// [SNI](https://geminiprotocol.net/docs/protocol-specification.gmi#server-name-indication) +fn network_address_for(uri: &Uri) -> Result { + Ok(NetworkAddress::new( + &match uri.host() { + Some(host) => host, + None => return Err(Error::Connectable(uri.to_string())), + }, + if uri.port().is_positive() { + uri.port() as u16 + } else { + DEFAULT_PORT + }, + )) +} + +fn request_async( + connection: Connection, + query: String, + priority: Priority, + cancellable: Cancellable, + callback: impl Fn(Result) + 'static, +) { + connection.stream().output_stream().write_bytes_async( + &Bytes::from(format!("{query}\r\n").as_bytes()), + priority, + Some(&cancellable.clone()), + move |result| match result { + Ok(_) => Response::from_request_async( + connection, + Some(priority), + Some(cancellable), + move |result| match result { + Ok(response) => callback(Ok(response)), + Err(reason) => callback(Err(Error::Response(reason))), + }, + ), + Err(reason) => callback(Err(Error::Write(reason))), + }, + ); +} diff --git a/src/client/connection.rs b/src/client/connection.rs new file mode 100644 index 0000000..d11f006 --- /dev/null +++ b/src/client/connection.rs @@ -0,0 +1,82 @@ +pub mod certificate; +pub mod error; + +pub use certificate::Certificate; +pub use error::Error; + +use gio::{ + prelude::{IOStreamExt, TlsConnectionExt}, + IOStream, NetworkAddress, SocketConnection, TlsCertificate, TlsClientConnection, +}; +use glib::object::{Cast, IsA}; + +pub struct Connection { + pub socket_connection: SocketConnection, + pub tls_client_connection: Option, +} + +impl Connection { + // Constructors + + /// Create new `Self` + pub fn from( + network_address: NetworkAddress, // @TODO struct cert as sni + socket_connection: SocketConnection, + certificate: Option, + ) -> Result { + if socket_connection.is_closed() { + return Err(Error::Closed); + } + + Ok(Self { + socket_connection: socket_connection.clone(), + tls_client_connection: match certificate { + Some(certificate) => match auth(network_address, socket_connection, certificate) { + Ok(tls_client_connection) => Some(tls_client_connection), + Err(reason) => return Err(reason), + }, + None => None, + }, + }) + } + + // Getters + + pub fn stream(&self) -> impl IsA { + match self.tls_client_connection.clone() { + Some(tls_client_connection) => tls_client_connection.upcast::(), + None => self.socket_connection.clone().upcast::(), + } + } +} + +// Tools + +pub fn auth( + server_identity: NetworkAddress, // @TODO impl IsA ? + socket_connection: SocketConnection, + certificate: TlsCertificate, +) -> Result { + if socket_connection.is_closed() { + return Err(Error::Closed); + } + + // https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls + match TlsClientConnection::new(&socket_connection, Some(&server_identity)) { + Ok(tls_client_connection) => { + // https://geminiprotocol.net/docs/protocol-specification.gmi#client-certificates + tls_client_connection.set_certificate(&certificate); + + // @TODO handle exceptions + // https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections + tls_client_connection.set_require_close_notify(true); + + // @TODO host validation + // https://geminiprotocol.net/docs/protocol-specification.gmi#tls-server-certificate-validation + tls_client_connection.connect_accept_certificate(move |_, _, _| true); + + Ok(tls_client_connection) + } + Err(reason) => Err(Error::Tls(reason)), + } +} diff --git a/src/client/connection/certificate.rs b/src/client/connection/certificate.rs new file mode 100644 index 0000000..f33e2f4 --- /dev/null +++ b/src/client/connection/certificate.rs @@ -0,0 +1,52 @@ +pub mod error; +pub mod scope; + +pub use error::Error; +pub use scope::Scope; + +use gio::{prelude::TlsCertificateExt, TlsCertificate}; +use glib::DateTime; + +pub struct Certificate { + tls_certificate: TlsCertificate, +} + +impl Certificate { + // Constructors + + /// Create new `Self` + pub fn from_pem(pem: &str) -> Result { + Ok(Self { + tls_certificate: match TlsCertificate::from_pem(&pem) { + Ok(tls_certificate) => { + // Validate expiration time + match DateTime::now_local() { + Ok(now_local) => { + match tls_certificate.not_valid_after() { + Some(not_valid_after) => { + if now_local > not_valid_after { + return Err(Error::Expired(not_valid_after)); + } + } + None => return Err(Error::ValidAfter), + } + match tls_certificate.not_valid_before() { + Some(not_valid_before) => { + if now_local < not_valid_before { + return Err(Error::Inactive(not_valid_before)); + } + } + None => return Err(Error::ValidBefore), + } + } + Err(_) => return Err(Error::DateTime), + } + + // Success + tls_certificate + } + Err(reason) => return Err(Error::Decode(reason)), + }, + }) + } +} diff --git a/src/client/connection/error.rs b/src/client/connection/error.rs new file mode 100644 index 0000000..aec2687 --- /dev/null +++ b/src/client/connection/error.rs @@ -0,0 +1,16 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Closed, + Tls(glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Closed => write!(f, "Socket connection closed"), + Self::Tls(reason) => write!(f, "Could not create TLS connection: {reason}"), + } + } +} diff --git a/src/client/error.rs b/src/client/error.rs new file mode 100644 index 0000000..a9eb0ac --- /dev/null +++ b/src/client/error.rs @@ -0,0 +1,36 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Connectable(String), + Connection(super::connection::Error), + Connect(glib::Error), + Request(glib::Error), + Response(super::response::Error), + Write(glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Connectable(uri) => { + write!(f, "Could not create connectable address for {uri}") + } + Self::Connection(reason) => { + write!(f, "Connection error: {reason}") + } + Self::Connect(reason) => { + write!(f, "Connect error: {reason}") + } + Self::Request(reason) => { + write!(f, "Request error: {reason}") + } + Self::Response(reason) => { + write!(f, "Response error: {reason}") + } + Self::Write(reason) => { + write!(f, "I/O Write error: {reason}") + } + } + } +} diff --git a/src/client/response.rs b/src/client/response.rs index e9ecdf2..96ab10b 100644 --- a/src/client/response.rs +++ b/src/client/response.rs @@ -1,6 +1,35 @@ //! Read and parse Gemini response as Object pub mod data; +pub mod error; pub mod meta; +pub use error::Error; pub use meta::Meta; + +use super::Connection; +use gio::Cancellable; +use glib::Priority; + +pub struct Response { + pub connection: Connection, + pub meta: Meta, +} + +impl Response { + // Constructors + + pub fn from_request_async( + connection: Connection, + priority: Option, + cancellable: Option, + callback: impl FnOnce(Result) + 'static, + ) { + Meta::from_stream_async(connection.stream(), priority, cancellable, |result| { + callback(match result { + Ok(meta) => Ok(Self { connection, meta }), + Err(reason) => Err(Error::Meta(reason)), + }) + }) + } +} diff --git a/src/client/response/data/text.rs b/src/client/response/data/text.rs index f2c814b..85312d6 100644 --- a/src/client/response/data/text.rs +++ b/src/client/response/data/text.rs @@ -16,7 +16,7 @@ pub const BUFFER_MAX_SIZE: usize = 0xfffff; // 1M /// Container for text-based response data pub struct Text { - data: GString, + pub data: GString, } impl Default for Text { @@ -41,10 +41,10 @@ impl Text { } /// Create new `Self` from UTF-8 buffer - pub fn from_utf8(buffer: &[u8]) -> Result)> { + pub fn from_utf8(buffer: &[u8]) -> Result { match GString::from_utf8(buffer.into()) { Ok(data) => Ok(Self::from_string(&data)), - Err(_) => Err((Error::Decode, None)), + Err(reason) => Err(Error::Decode(reason)), } } @@ -53,7 +53,7 @@ impl Text { stream: impl IsA, priority: Option, cancellable: Option, - on_complete: impl FnOnce(Result)>) + 'static, + on_complete: impl FnOnce(Result) + 'static, ) { read_all_from_stream_async( Vec::with_capacity(BUFFER_CAPACITY), @@ -72,13 +72,6 @@ impl Text { }, ); } - - // Getters - - /// Get reference to `Self` data - pub fn data(&self) -> &GString { - &self.data - } } // Tools @@ -92,7 +85,7 @@ pub fn read_all_from_stream_async( stream: impl IsA, cancelable: Option, priority: Priority, - callback: impl FnOnce(Result, (Error, Option<&str>)>) + 'static, + callback: impl FnOnce(Result, Error>) + 'static, ) { stream.input_stream().read_bytes_async( BUFFER_CAPACITY, @@ -107,7 +100,7 @@ pub fn read_all_from_stream_async( // Validate overflow if buffer.len() + bytes.len() > BUFFER_MAX_SIZE { - return callback(Err((Error::BufferOverflow, None))); + return callback(Err(Error::BufferOverflow)); } // Save chunks to buffer @@ -118,7 +111,7 @@ pub fn read_all_from_stream_async( // Continue bytes reading read_all_from_stream_async(buffer, stream, cancelable, priority, callback); } - Err(reason) => callback(Err((Error::InputStream, Some(reason.message())))), + Err(reason) => callback(Err(Error::InputStreamRead(reason))), }, ); } diff --git a/src/client/response/data/text/error.rs b/src/client/response/data/text/error.rs index 3007499..5de7592 100644 --- a/src/client/response/data/text/error.rs +++ b/src/client/response/data/text/error.rs @@ -1,6 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { BufferOverflow, - Decode, - InputStream, + Decode(std::string::FromUtf8Error), + InputStreamRead(glib::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::BufferOverflow => { + write!(f, "Buffer overflow") + } + Self::Decode(reason) => { + write!(f, "Decode error: {reason}") + } + Self::InputStreamRead(reason) => { + write!(f, "Input stream read error: {reason}") + } + } + } } diff --git a/src/client/response/error.rs b/src/client/response/error.rs new file mode 100644 index 0000000..9b3afb8 --- /dev/null +++ b/src/client/response/error.rs @@ -0,0 +1,20 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Meta(super::meta::Error), + Stream, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Meta(reason) => { + write!(f, "Meta read error: {reason}") + } + Self::Stream => { + write!(f, "I/O stream error") + } + } + } +} diff --git a/src/client/response/meta.rs b/src/client/response/meta.rs index 82b5f3e..29d05bd 100644 --- a/src/client/response/meta.rs +++ b/src/client/response/meta.rs @@ -22,9 +22,9 @@ use glib::{object::IsA, Priority}; pub const MAX_LEN: usize = 0x400; // 1024 pub struct Meta { - status: Status, - data: Option, - mime: Option, + pub status: Status, + pub data: Option, + pub mime: Option, // @TODO // charset: Option, // language: Option, @@ -35,7 +35,7 @@ impl Meta { /// Create new `Self` from UTF-8 buffer /// * supports entire response or just meta slice - pub fn from_utf8(buffer: &[u8]) -> Result)> { + pub fn from_utf8(buffer: &[u8]) -> Result { // Calculate buffer length once let len = buffer.len(); @@ -46,13 +46,7 @@ impl Meta { 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, - )); + return Err(Error::Data(reason)); } // MIME @@ -60,14 +54,7 @@ impl Meta { 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, - )); + return Err(Error::Mime(reason)); } // Status @@ -75,14 +62,7 @@ impl Meta { 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, - )); + return Err(Error::Status(reason)); } Ok(Self { @@ -91,7 +71,7 @@ impl Meta { status: status.unwrap(), }) } - None => Err((Error::Protocol, None)), + None => Err(Error::Protocol), } } @@ -100,7 +80,7 @@ impl Meta { stream: impl IsA, priority: Option, cancellable: Option, - on_complete: impl FnOnce(Result)>) + 'static, + on_complete: impl FnOnce(Result) + 'static, ) { read_from_stream_async( Vec::with_capacity(MAX_LEN), @@ -119,20 +99,6 @@ impl Meta { }, ); } - - // Getters - - pub fn status(&self) -> &Status { - &self.status - } - - pub fn data(&self) -> &Option { - &self.data - } - - pub fn mime(&self) -> &Option { - &self.mime - } } // Tools @@ -146,7 +112,7 @@ pub fn read_from_stream_async( stream: impl IsA, cancellable: Option, priority: Priority, - on_complete: impl FnOnce(Result, (Error, Option<&str>)>) + 'static, + on_complete: impl FnOnce(Result, Error>) + 'static, ) { stream.input_stream().read_async( vec![0], @@ -156,7 +122,7 @@ pub fn read_from_stream_async( Ok((mut bytes, size)) => { // Expect valid header length if size == 0 || buffer.len() >= MAX_LEN { - return on_complete(Err((Error::Protocol, None))); + return on_complete(Err(Error::Protocol)); } // Read next byte without record @@ -181,7 +147,7 @@ pub fn read_from_stream_async( // Continue read_from_stream_async(buffer, stream, cancellable, priority, on_complete); } - Err((_, reason)) => on_complete(Err((Error::InputStream, Some(reason.message())))), + Err((data, reason)) => on_complete(Err(Error::InputStreamRead(data, reason))), }, ); } diff --git a/src/client/response/meta/data.rs b/src/client/response/meta/data.rs index 710d4f6..0a67b10 100644 --- a/src/client/response/meta/data.rs +++ b/src/client/response/meta/data.rs @@ -12,7 +12,7 @@ use glib::GString; /// * placeholder text for 10, 11 status /// * URL string for 30, 31 status pub struct Data { - value: GString, + pub value: GString, } impl Data { @@ -52,16 +52,10 @@ impl Data { false => Some(Self { value }), true => None, }), - Err(_) => Err(Error::Decode), + Err(reason) => Err(Error::Decode(reason)), } } None => Err(Error::Protocol), } } - - // Getters - - pub fn value(&self) -> &GString { - &self.value - } } diff --git a/src/client/response/meta/data/error.rs b/src/client/response/meta/data/error.rs index 125f9c6..6681812 100644 --- a/src/client/response/meta/data/error.rs +++ b/src/client/response/meta/data/error.rs @@ -1,5 +1,20 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { - Decode, + Decode(std::string::FromUtf8Error), Protocol, } + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Decode(reason) => { + write!(f, "Decode error: {reason}") + } + Self::Protocol => { + write!(f, "Protocol error") + } + } + } +} diff --git a/src/client/response/meta/error.rs b/src/client/response/meta/error.rs index b1414eb..9688a4b 100644 --- a/src/client/response/meta/error.rs +++ b/src/client/response/meta/error.rs @@ -1,13 +1,33 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { - DataDecode, - DataProtocol, - InputStream, - MimeDecode, - MimeProtocol, - MimeUndefined, + Data(super::data::Error), + InputStreamRead(Vec, glib::Error), + Mime(super::mime::Error), Protocol, - StatusDecode, - StatusProtocol, - StatusUndefined, + Status(super::status::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Data(reason) => { + write!(f, "Data error: {reason}") + } + Self::InputStreamRead(_, reason) => { + // @TODO + write!(f, "Input stream error: {reason}") + } + Self::Mime(reason) => { + write!(f, "MIME error: {reason}") + } + Self::Protocol => { + write!(f, "Protocol error") + } + Self::Status(reason) => { + write!(f, "Status error: {reason}") + } + } + } } diff --git a/src/client/response/meta/mime.rs b/src/client/response/meta/mime.rs index ec8849d..4a4f7f2 100644 --- a/src/client/response/meta/mime.rs +++ b/src/client/response/meta/mime.rs @@ -47,7 +47,7 @@ impl Mime { 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), + Err(reason) => Err(Error::Decode(reason)), }, None => Err(Error::Protocol), } diff --git a/src/client/response/meta/mime/error.rs b/src/client/response/meta/mime/error.rs index 989e734..5b9eccc 100644 --- a/src/client/response/meta/mime/error.rs +++ b/src/client/response/meta/mime/error.rs @@ -1,6 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { - Decode, + Decode(std::string::FromUtf8Error), Protocol, Undefined, } + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Decode(reason) => { + write!(f, "Decode error: {reason}") + } + Self::Protocol => { + write!(f, "Protocol error") + } + Self::Undefined => { + write!(f, "Undefined error") + } + } + } +} diff --git a/src/client/response/meta/status.rs b/src/client/response/meta/status.rs index 1d0c719..5a5062a 100644 --- a/src/client/response/meta/status.rs +++ b/src/client/response/meta/status.rs @@ -43,7 +43,7 @@ impl Status { 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), + Err(reason) => Err(Error::Decode(reason)), }, None => Err(Error::Protocol), } diff --git a/src/client/response/meta/status/error.rs b/src/client/response/meta/status/error.rs index 989e734..5b9eccc 100644 --- a/src/client/response/meta/status/error.rs +++ b/src/client/response/meta/status/error.rs @@ -1,6 +1,24 @@ +use std::fmt::{Display, Formatter, Result}; + #[derive(Debug)] pub enum Error { - Decode, + Decode(std::string::FromUtf8Error), Protocol, Undefined, } + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Decode(reason) => { + write!(f, "Decode error: {reason}") + } + Self::Protocol => { + write!(f, "Protocol error") + } + Self::Undefined => { + write!(f, "Undefined error") + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 26403ca..350fdd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,4 @@ pub mod client; pub mod gio; + +pub use client::Client;