M Cargo.lock => Cargo.lock +79 -0
@@ 94,6 94,7 @@ dependencies = [
"native-tls",
"tokio",
"tokio-native-tls",
+ "url",
"webpki-roots",
]
@@ 129,6 130,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
+name = "form_urlencoded"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
+dependencies = [
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
name = "getrandom"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 164,6 175,17 @@ dependencies = [
]
[[package]]
+name = "idna"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
+dependencies = [
+ "matches",
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
name = "indexmap"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 204,6 226,12 @@ dependencies = [
]
[[package]]
+name = "matches"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"
+
+[[package]]
name = "memchr"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 314,6 342,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85"
[[package]]
+name = "percent-encoding"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
+
+[[package]]
name = "pin-project-lite"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 535,6 569,21 @@ dependencies = [
]
[[package]]
+name = "tinyvec"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "848a1e1181b9f6753b5e96a092749e29b11d19ede67dfbbd6c7dc7e0f49b5338"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
+
+[[package]]
name = "tokio"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 573,6 622,24 @@ dependencies = [
]
[[package]]
+name = "unicode-bidi"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0"
+dependencies = [
+ "matches",
+]
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
name = "unicode-segmentation"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 597,6 664,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
+name = "url"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml => Cargo.toml +1 -0
@@ 12,4 12,5 @@ clap = "3.0.0-beta.2"
native-tls = "0.2"
tokio = { version = "1.9.0", features = ["rt-multi-thread", "macros", "net", "io-std", "io-util"] }
tokio-native-tls = "0.3"
+url = "2.2.2"
webpki-roots = "0.21"
M src/main.rs => src/main.rs +13 -12
@@ 1,6 1,7 @@
use anyhow::{anyhow, Context, Result};
use clap::{AppSettings, Clap};
use native_tls::TlsConnector;
+use std::convert::TryFrom;
use std::fs::File;
use std::io;
use std::io::BufReader;
@@ 10,6 11,7 @@ use std::sync::Arc;
use tokio::io::{copy, split, stdout as tokio_stdout, AsyncWriteExt};
use tokio::net::TcpStream;
+mod request;
mod response;
/// Coil is a Curl-like client for the Gemini web protocol.
@@ 18,6 20,7 @@ mod response;
#[clap(setting = AppSettings::ColoredHelp)]
struct Coil {
/// The hostname of the server to send the request.
+ /// The Gemini protocol is inferred."
hostname: String,
/// Port number of the listening server.
#[clap(short, long, default_value = "1965")]
@@ 28,14 31,12 @@ struct Coil {
async fn main() -> Result<()> {
let coil: Coil = Coil::parse();
- // Open TCP connection
- // let hostname = "gemini.circumlunar.space";
- let protocol = "gemini://";
- // The year of the first manned Gemini mission
- //let port: u16 = 1965;
+ let req = request::Request::try_from(coil.hostname).context("Error creating request")?;
- let addr = (coil.hostname.as_str(), coil.port)
- .to_socket_addrs()?
+ // Open TCP connection
+ let addr = (req.host.clone(), coil.port)
+ .to_socket_addrs()
+ .context("Error parsing hostname")?
.next()
.context("Could not get hostname address")?;
@@ 45,16 46,16 @@ async fn main() -> Result<()> {
let connector = tokio_native_tls::TlsConnector::from(connector);
// Open connection
- let stream = TcpStream::connect(&addr).await?;
+ let stream = TcpStream::connect(&addr)
+ .await
+ .context("TCP Connection failure")?;
let mut stream = connector
- .connect(coil.hostname.as_str(), stream)
+ .connect(req.host.as_str(), stream)
.await
.context("Connection failed")?;
- let req = format!("{}{}/\r\n", protocol, coil.hostname,);
-
- stream.write_all(req.as_bytes()).await?;
+ stream.write_all(&req.as_bytes()).await?;
let (mut reader, mut writer) = split(stream);
M src/request.rs => src/request.rs +101 -2
@@ 1,3 1,102 @@
-struct Request {
- url: String,
+use anyhow::{anyhow, Context, Result};
+use std::convert::TryFrom;
+use url::{Host, Url};
+
+#[derive(Debug, Eq, PartialEq)]
+pub struct Request {
+ pub protocol: String,
+ pub host: String,
+ pub path: String,
+}
+
+impl Request {
+ pub fn as_bytes<'a>(self) -> Vec<u8> {
+ format!("{}://{}{}\r\n", self.protocol, self.host, self.path).into_bytes()
+ }
+}
+
+impl TryFrom<String> for Request {
+ type Error = anyhow::Error;
+
+ fn try_from(input: String) -> Result<Request> {
+ let mut url = String::new();
+
+ // Ensure the scheme is specified and is Gemini
+ if !input.starts_with("gemini://") && !input.contains("://") {
+ url.push_str(format!("gemini://{}", input).as_str());
+ } else if input.starts_with("gemini://") {
+ url.push_str(input.as_str());
+ } else {
+ let i = input.find("://").unwrap();
+ let scheme = input.get(0..i).unwrap();
+ return Err(anyhow!("Unsupported scheme: {}", scheme));
+ }
+
+ let mut url = Url::parse(url.as_str()).context("Error parsing URL")?;
+
+ // Set path if path is empty
+ if url.path() == "" {
+ url.set_path("/");
+ };
+
+ Ok(Request {
+ protocol: url.scheme().to_string(),
+ host: url.host_str().unwrap().to_string(),
+ path: url.path().to_string(),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_ok_hostname_ok_scheme() {
+ let url = String::from("gemini://hostname.com/path/to/file");
+ let r = Request::try_from(url).unwrap();
+ let expected = Request {
+ protocol: String::from("gemini"),
+ host: String::from("hostname.com"),
+ path: String::from("/path/to/file"),
+ };
+ assert_eq!(r, expected);
+ }
+
+ #[test]
+ fn parse_ok_hostname_no_scheme() {
+ let url = String::from("hostname.com/path/to/file");
+ let r = Request::try_from(url).unwrap();
+ let expected = Request {
+ protocol: String::from("gemini"),
+ host: String::from("hostname.com"),
+ path: String::from("/path/to/file"),
+ };
+ assert_eq!(r, expected);
+ }
+ #[test]
+ fn parse_ok_hostname_no_path() {
+ let url = String::from("gemini://hostname.com");
+ let r = Request::try_from(url).unwrap();
+ let expected = Request {
+ protocol: String::from("gemini"),
+ host: String::from("hostname.com"),
+ path: String::from("/"),
+ };
+ assert_eq!(r, expected);
+ }
+
+ #[test]
+ fn parse_bad_hostname_ok_scheme() {
+ let url = String::from("gemini://host&^name.com/path/to/file");
+ let r = Request::try_from(url);
+ assert!(r.is_err());
+ }
+
+ #[test]
+ fn parse_ok_hostname_bad_scheme() {
+ let url = String::from("https://hostname.com/path/to/file");
+ let r = Request::try_from(url);
+ assert!(r.is_err());
+ }
}