~athorp96/Coil

c81468ac2358f5dc7c7af095221d078c15da0ad9 — Andrew Thorp 3 years ago 266c4b7
Parse URL and add scheme when missing
4 files changed, 194 insertions(+), 14 deletions(-)

M Cargo.lock
M Cargo.toml
M src/main.rs
M src/request.rs
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());
    }
}