gload 0.5.1

A command line client for the Gemini protocol.
Documentation
//! This software is licensed as described in the file LICENSE, which
//! you should have received as part of this distribution.
//!
//! You may opt to use, copy, modify, merge, publish, distribute and/or sell
//! copies of the Software, and permit persons to whom the Software is
//! furnished to do so, under the terms of the LICENSE file.
//!
//! This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
//! KIND, either express or implied.
//!
//! SPDX-License-Identifier: BSD-3-Clause

use core::time::Duration;
use gload::{
	net::SendError,
	request::RequestConstructError,
	response::{InvalidStatusCode, ResponseParseError, StatusCode},
	tls::TlsError,
};
use rustls::pki_types::ServerName;
use std::process::{ExitCode, Termination};

/// The reason for which the process ended.
#[derive(Debug)]
pub(crate) enum ExitReason {
	/// The server failed to send close_notify before closing the connection.
	AbruptClosure,

	/// We cannot send binary to stdout without user permission, so we warn and exit.
	BinaryOutputWarning,

	/// A signal caused the client to cancel. (e.g. SIGINT)
	Cancelled,

	/// The server response indicates that there was a problem validating
	/// the client certificate.
	ClientCertificateNotValid,

	/// The server response indicates that a client certificate,
	/// is required, but none was provided.
	ClientCertificateRequired,

	/// The given remote host's address was not resolved.
	CouldNotResolveHost(url::Host),

	/// The TLS handshake failed.
	InappropriateHandshakeMessage,

	/// We managed to get an IP address to the machine and tried to set
	/// up a TCP connection to the host but failed.
	InitialConnect(std::io::Error, ServerName<'static>, u16),

	/// The server response indicates it expects input, but we haven't
	/// implmemented that function yet.
	InputExpected,

	/// The server replied with nothing, or didn't reply at all.
	NoResponse,

	/// Failed to open a TCP socket.
	Open(std::io::Error),

	/// Failed to read data from the server.
	Receive(std::io::Error),

	/// Failed to send data to the server.
	Send(std::io::Error),

	/// Failed to verify the server certificate.
	ServerCertificate(rustls::CertificateError),

	/// The server response contained a status code of 40 or greater,
	/// and the user requested that we exit in this case.
	ServerErrorResponse(StatusCode),

	/// Failed to shut down the TCP connection.
	ShutdownFailed(std::io::Error),

	/// The process finished successfully.
	Success,

	/// Timed out while opening a socket or awaiting a response.
	TimedOut(ServerName<'static>, u16, Duration),

	/// If we continue, we'll exceeded a maximum number of redirects.
	TooManyRedirects { max: u8 },

	/// An unsupported URI scheme was provided. gload only supports
	/// `gemini` links.
	UnsupportedProtocol(String),

	/// The URL syntax was not correct, the URI contained userinfo data,
	/// or the encoded URI exceeded 1024 bytes.
	///
	/// Spec: "the userinfo portion of a URI MUST NOT be used;"
	UrlMalformed,

	/// The server response couldn't be parsed for some reason. (Non-UTF-8
	/// header, shorter than a status code, unknown status code, etc.)
	WeirdServerReply(String),

	/// Could not write data to the local filesystem.
	WriteToFile(usize),

	/// Could not write data to standard output.
	WriteToStdout(usize),
}

impl From<TlsError> for ExitReason {
	fn from(value: TlsError) -> Self {
		match value {
			TlsError::InappropriateHandshakeMessage => Self::InappropriateHandshakeMessage,
			TlsError::InitialConnect(err, authority, port) => {
				Self::InitialConnect(err, authority, port)
			}
			TlsError::ClosedWithoutNotify => Self::AbruptClosure,
			TlsError::NoResponse => Self::NoResponse,
			TlsError::Open(err) => Self::Open(err),
			TlsError::Receive(err) => Self::Receive(err),
			TlsError::Send(err) => Self::Send(err),
			TlsError::ServerCertificate(err) => Self::ServerCertificate(err),
			TlsError::ShutdownFailed(err) => Self::ShutdownFailed(err),
			TlsError::TimedOut(authority, port, duration) => {
				Self::TimedOut(authority, port, duration)
			}
			_ => todo!("ExitReason must be updated for new values of TlsError"),
		}
	}
}

impl From<RequestConstructError> for ExitReason {
	fn from(value: RequestConstructError) -> Self {
		match value {
			RequestConstructError::MissingAuthority => Self::UrlMalformed,
			RequestConstructError::RequestTooLongError(_) => Self::UrlMalformed,
			RequestConstructError::UnsupportedProtocol(scheme) => Self::UnsupportedProtocol(scheme),
			RequestConstructError::UrlParse(_) => Self::UrlMalformed,
			RequestConstructError::Userinfo => Self::UrlMalformed,
		}
	}
}

impl<H: ::gload::net::ResponseHandler<Error = crate::PrintingError>> From<SendError<H>>
	for ExitReason
{
	fn from(value: SendError<H>) -> Self {
		match value {
			SendError::Cancelled => Self::Cancelled,
			SendError::CouldNotResolveHost(host) => Self::CouldNotResolveHost(host),
			SendError::Handler(crate::PrintingError::BinaryOutput) => Self::BinaryOutputWarning,
			SendError::Handler(crate::PrintingError::File(count)) => Self::WriteToFile(count),
			SendError::Handler(crate::PrintingError::Stdout(count)) => Self::WriteToStdout(count),
			SendError::RequestConstruct(err) => err.into(),
			SendError::ResponseParse(err) => err.into(),
			SendError::Tls(err) => err.into(),
			SendError::TooManyRedirects { max } => Self::TooManyRedirects { max },
			_ => todo!("ExitReason must be updated for new values of SendError"),
		}
	}
}

impl From<ResponseParseError> for ExitReason {
	fn from(value: ResponseParseError) -> Self {
		Self::WeirdServerReply(format!("{value}"))
	}
}

impl From<InvalidStatusCode> for ExitReason {
	fn from(value: InvalidStatusCode) -> Self {
		Self::WeirdServerReply(format!("{value}"))
	}
}

impl ExitReason {
	/// The raw exit code with which the process should end.
	#[cfg(not(tarpaulin_include))]
	const fn raw_exit_code(&self) -> u8 {
		match self {
			// These are loosely based on cURL's exit codes; https://everything.curl.dev/cmdline/exitcode.html
			Self::Success => 0,
			Self::InputExpected | Self::UnsupportedProtocol(_) => 1,
			Self::UrlMalformed => 3,
			Self::CouldNotResolveHost(_) => 6,
			Self::InitialConnect(_, _, _) => 7,
			Self::WeirdServerReply(_) => 8,
			Self::ServerErrorResponse(_) => 22,
			Self::BinaryOutputWarning | Self::WriteToFile(_) | Self::WriteToStdout(_) => 23,
			Self::TimedOut(_, _, _) => 28,
			Self::InappropriateHandshakeMessage => 35,
			Self::TooManyRedirects { .. } => 47,
			Self::NoResponse => 52,
			Self::Open(_) | Self::Send(_) => 55,
			Self::AbruptClosure | Self::Receive(_) => 56,
			// Self::CouldNotReadAuthority => 77, // TODO: An error when we fail to read the system's CA cert bundle
			Self::ShutdownFailed(_) => 80,
			Self::ServerCertificate(rustls::CertificateError::UnknownIssuer) => 83,
			Self::ServerCertificate(_) => 91,
			Self::ClientCertificateNotValid => 94,
			Self::ClientCertificateRequired => 98,
			Self::Cancelled => 130, // TODO: Should we use 143 instead if the signal was SIGTERM?
		}
	}

	/// The exit code with which the process should end.
	#[cfg(not(tarpaulin_include))]
	fn exit_code(&self) -> ExitCode {
		ExitCode::from(self.raw_exit_code())
	}
}

#[cfg(not(tarpaulin_include))]
impl core::fmt::Display for ExitReason {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		match self {
			// These are mainly based on cURL's output
			Self::AbruptClosure => write!(f, "Failure when receiving data from the peer"),
			Self::BinaryOutputWarning | Self::Cancelled | Self::Success => write!(f, ""),
			Self::ClientCertificateNotValid => {
				write!(f, "Authentication error; SSL Client Certificate is invalid")
			}
			Self::ClientCertificateRequired => write!(f, "SSL Client Certificate required"),
			Self::CouldNotResolveHost(host) => write!(f, "Could not resolve host: {host}"),
			Self::InappropriateHandshakeMessage => write!(f, "SSL handshake failed"),
			Self::InitialConnect(err, authority, port) => write!(
				f,
				"Failed to connect to {} port {port}: {err}",
				authority.to_str()
			),
			Self::InputExpected => write!(
				f,
				"The server response expects input, but we haven't implemented that yet"
			),
			Self::NoResponse => write!(f, "The server didn't send any response"),
			Self::Open(err) => write!(f, "Could not open a TCP socket: {err}"),
			Self::Receive(err) => write!(f, "Could not read server response: {err}"),
			Self::Send(err) => write!(f, "Could not send data to server: {err}"),
			Self::ServerCertificate(err) => write!(f, "SSL certificate problem: {err}"),
			Self::ServerErrorResponse(status) => {
				write!(f, "The requested URL returned error: {}", status.as_u8())
			}
			Self::ShutdownFailed(err) => write!(f, "Failed to shut down the SSL connection: {err}"),
			Self::TimedOut(authority, port, duration) => write!(
				f,
				"Failed to connect to {} port {port} after {} ms: Could not connect to server",
				authority.to_str(),
				duration.as_millis()
			),
			Self::TooManyRedirects { max } => write!(f, "Maximum ({max}) redirects followed"),
			Self::UnsupportedProtocol(scheme) => write!(f, r#"Protocol "{scheme}" not supported"#),
			Self::UrlMalformed => write!(f, "URL rejected: Malformed input to a URL function"),
			Self::WeirdServerReply(msg) => write!(f, "{msg}"),
			Self::WriteToFile(count) => {
				write!(f, "client returned ERROR on write of {count} bytes")
			}
			Self::WriteToStdout(count) => {
				write!(f, "Failure writing {count} bytes to destination")
			}
		}
	}
}

static CRATE_NAME: &str = clap::crate_name!();

#[cfg(not(tarpaulin_include))]
impl Termination for ExitReason {
	fn report(self) -> ExitCode {
		#[cfg(feature = "logging")]
		if !matches!(
			self,
			Self::BinaryOutputWarning | Self::Cancelled | Self::Success
		) {
			let code = self.raw_exit_code();
			::log::error!("{CRATE_NAME}: ({code}) {self}");
		}

		self.exit_code()
	}
}