greentic-component-store 0.4.76

Store abstraction and verification for Greentic components
Documentation
#[cfg(feature = "http")]
use reqwest::blocking::Client;
#[cfg(feature = "http")]
use reqwest::blocking::{RequestBuilder, Response};
#[cfg(feature = "http")]
use reqwest::header::{ACCEPT, HeaderName, HeaderValue, USER_AGENT};
#[cfg(feature = "http")]
use url::Url;

use crate::StoreError;

#[cfg(feature = "http")]
const USER_AGENT_VALUE: &str = concat!("greentic-component/", env!("CARGO_PKG_VERSION"));
#[cfg(feature = "http")]
const ACCEPT_VALUE: &str = "application/wasm,application/octet-stream";

#[cfg(feature = "http")]
#[inline(never)]
pub fn build_client() -> Result<Client, StoreError> {
    Client::builder()
        .user_agent(USER_AGENT_VALUE)
        .build()
        .map_err(StoreError::from)
}

#[cfg(feature = "http")]
#[inline(never)]
pub fn fetch(client: &Client, url: &Url) -> Result<Vec<u8>, StoreError> {
    let request = build_request(client, url);
    let response = send_request(request)?;
    response_to_bytes(response)
}

#[cfg(feature = "http")]
#[inline(never)]
fn build_request(client: &Client, url: &Url) -> RequestBuilder {
    apply_component_headers(client.get(url.clone()))
}

#[cfg(feature = "http")]
fn apply_component_headers(builder: RequestBuilder) -> RequestBuilder {
    component_headers()
        .into_iter()
        .fold(builder, |builder, (name, value)| {
            builder.header(name, value)
        })
}

#[cfg(feature = "http")]
fn component_headers() -> [(HeaderName, HeaderValue); 2] {
    [
        (USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE)),
        (ACCEPT, HeaderValue::from_static(ACCEPT_VALUE)),
    ]
}

#[cfg(feature = "http")]
#[inline(never)]
fn send_request(builder: RequestBuilder) -> Result<Response, StoreError> {
    let response = builder.send().map_err(StoreError::from)?;
    Ok(response)
}

#[cfg(feature = "http")]
#[inline(never)]
fn response_to_bytes(response: Response) -> Result<Vec<u8>, StoreError> {
    let response = response.error_for_status().map_err(StoreError::from)?;
    let bytes = response.bytes().map_err(StoreError::from)?;
    Ok(bytes.to_vec())
}

#[cfg(not(feature = "http"))]
pub fn build_client() -> Result<(), StoreError> {
    Err(StoreError::UnsupportedScheme("http".into()))
}

#[cfg(not(feature = "http"))]
pub fn fetch(_client: &(), _url: &url::Url) -> Result<Vec<u8>, StoreError> {
    Err(StoreError::UnsupportedScheme("http".into()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{ErrorKind, Read, Write};
    use std::net::TcpListener;
    use std::thread::{self, JoinHandle};

    fn spawn_status_server(
        status: &str,
        content_type: &str,
        body: &'static [u8],
    ) -> std::io::Result<Url> {
        let listener = TcpListener::bind("127.0.0.1:0")?;
        let addr = listener.local_addr()?;
        let status = status.to_string();
        let content_type = content_type.to_string();

        thread::spawn(move || {
            if let Ok((mut stream, _)) = listener.accept() {
                let mut buffer = [0u8; 512];
                let _ = stream.read(&mut buffer);
                let response = format!(
                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\n\r\n",
                    body.len()
                );
                let _ = stream.write_all(response.as_bytes());
                let _ = stream.write_all(body);
            }
        });

        Ok(Url::parse(&format!(
            "http://{}:{}/component.wasm",
            addr.ip(),
            addr.port()
        ))
        .expect("url"))
    }

    fn spawn_recording_server(
        status: &str,
        content_type: &str,
        body: &'static [u8],
    ) -> std::io::Result<(Url, JoinHandle<Vec<u8>>)> {
        let listener = TcpListener::bind("127.0.0.1:0")?;
        let addr = listener.local_addr()?;
        let status = status.to_string();
        let content_type = content_type.to_string();

        let handle = thread::spawn(move || {
            let (mut stream, _) = listener.accept().expect("accept");
            let mut request = Vec::new();
            let mut buffer = [0u8; 1024];
            loop {
                let read = stream.read(&mut buffer).expect("read request");
                if read == 0 {
                    break;
                }
                request.extend_from_slice(&buffer[..read]);
                if request.windows(4).any(|window| window == b"\r\n\r\n") {
                    break;
                }
            }

            let response = format!(
                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\n\r\n",
                body.len()
            );
            stream
                .write_all(response.as_bytes())
                .expect("write response headers");
            stream.write_all(body).expect("write response body");
            request
        });

        let url = Url::parse(&format!(
            "http://{}:{}/component.wasm",
            addr.ip(),
            addr.port()
        ))
        .expect("url");
        Ok((url, handle))
    }

    #[test]
    fn build_client_succeeds() {
        let client = build_client().expect("client");
        drop(client);
    }

    #[test]
    fn build_request_sets_expected_method_url_and_headers() {
        let client = build_client().expect("client");
        let url = Url::parse("http://127.0.0.1:8080/component.wasm").expect("url");

        let request = build_request(&client, &url).build().expect("request");

        assert_eq!(request.method(), reqwest::Method::GET);
        assert_eq!(request.url().as_str(), url.as_str());
        assert_eq!(
            request.headers().get(ACCEPT).and_then(|v| v.to_str().ok()),
            Some(ACCEPT_VALUE)
        );
        assert_eq!(
            request
                .headers()
                .get(USER_AGENT)
                .and_then(|v| v.to_str().ok()),
            Some(USER_AGENT_VALUE)
        );
    }

    #[test]
    fn component_headers_match_expected_constants() {
        let headers = component_headers();

        assert_eq!(headers[0].0, USER_AGENT);
        assert_eq!(headers[0].1.to_str().ok(), Some(USER_AGENT_VALUE));
        assert_eq!(headers[1].0, ACCEPT);
        assert_eq!(headers[1].1.to_str().ok(), Some(ACCEPT_VALUE));
    }

    #[test]
    fn apply_component_headers_adds_expected_headers() {
        let client = build_client().expect("client");
        let url = Url::parse("http://127.0.0.1:8080/component.wasm").expect("url");

        let request = apply_component_headers(client.get(url))
            .build()
            .expect("request");

        assert_eq!(
            request.headers().get(ACCEPT).and_then(|v| v.to_str().ok()),
            Some(ACCEPT_VALUE)
        );
        assert_eq!(
            request
                .headers()
                .get(USER_AGENT)
                .and_then(|v| v.to_str().ok()),
            Some(USER_AGENT_VALUE)
        );
    }

    #[test]
    fn fetch_returns_http_error_for_unsuccessful_status() {
        let url = match spawn_status_server("404 Not Found", "text/plain", b"missing") {
            Ok(url) => url,
            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                eprintln!("skipping fetch_returns_http_error_for_unsuccessful_status: {err}");
                return;
            }
            Err(err) => panic!("bind http listener: {err}"),
        };

        let client = build_client().expect("client");
        let err = fetch(&client, &url).expect_err("404 should fail");
        assert!(matches!(err, StoreError::Http(_)));
    }

    #[test]
    fn fetch_returns_response_body_for_successful_status() {
        let url = match spawn_status_server("200 OK", "application/wasm", b"\0asm\x01\x00\x00\x00")
        {
            Ok(url) => url,
            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                eprintln!("skipping fetch_returns_response_body_for_successful_status: {err}");
                return;
            }
            Err(err) => panic!("bind http listener: {err}"),
        };

        let client = build_client().expect("client");
        let body = fetch(&client, &url).expect("200 response should succeed");
        assert_eq!(body, b"\0asm\x01\x00\x00\x00");
    }

    #[test]
    fn fetch_sends_expected_headers_on_wire_and_returns_body() {
        let (url, request_handle) =
            match spawn_recording_server("200 OK", "application/wasm", b"\0asm\x01\x00\x00\x00") {
                Ok(server) => server,
                Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                    eprintln!(
                        "skipping fetch_sends_expected_headers_on_wire_and_returns_body: {err}"
                    );
                    return;
                }
                Err(err) => panic!("bind http listener: {err}"),
            };

        let client = build_client().expect("client");
        let body = fetch(&client, &url).expect("fetch should succeed");
        let request = request_handle.join().expect("join server thread");
        let request = String::from_utf8(request).expect("utf-8 request");

        assert_eq!(body, b"\0asm\x01\x00\x00\x00");
        assert!(request.starts_with("GET /component.wasm HTTP/1.1\r\n"));
        assert!(request.contains(&format!("accept: {ACCEPT_VALUE}\r\n")));
        assert!(request.contains(&format!("user-agent: {USER_AGENT_VALUE}\r\n")));
    }

    #[test]
    fn response_to_bytes_returns_body_for_successful_response() {
        let url = match spawn_status_server("200 OK", "application/wasm", b"abc") {
            Ok(url) => url,
            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                eprintln!("skipping response_to_bytes_returns_body_for_successful_response: {err}");
                return;
            }
            Err(err) => panic!("bind http listener: {err}"),
        };

        let client = build_client().expect("client");
        let response = send_request(build_request(&client, &url)).expect("send request");

        let body = response_to_bytes(response).expect("response bytes");
        assert_eq!(body, b"abc");
    }

    #[test]
    fn response_to_bytes_returns_http_error_for_unsuccessful_response() {
        let url = match spawn_status_server("404 Not Found", "text/plain", b"missing") {
            Ok(url) => url,
            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                eprintln!(
                    "skipping response_to_bytes_returns_http_error_for_unsuccessful_response: {err}"
                );
                return;
            }
            Err(err) => panic!("bind http listener: {err}"),
        };

        let client = build_client().expect("client");
        let response = send_request(build_request(&client, &url)).expect("send request");

        let err = response_to_bytes(response).expect_err("404 response should fail");
        assert!(matches!(err, StoreError::Http(_)));
    }

    #[test]
    fn send_request_returns_response_for_successful_server() {
        let url = match spawn_status_server("200 OK", "application/wasm", b"ok") {
            Ok(url) => url,
            Err(err) if err.kind() == ErrorKind::PermissionDenied => {
                eprintln!("skipping send_request_returns_response_for_successful_server: {err}");
                return;
            }
            Err(err) => panic!("bind http listener: {err}"),
        };

        let client = build_client().expect("client");
        let response = send_request(build_request(&client, &url)).expect("send request");

        assert_eq!(response.status(), reqwest::StatusCode::OK);
    }

    #[test]
    fn send_request_returns_http_error_for_unreachable_server() {
        let client = build_client().expect("client");
        let url = Url::parse("http://127.0.0.1:9/component.wasm").expect("url");

        let err = send_request(build_request(&client, &url)).expect_err("request should fail");

        assert!(matches!(err, StoreError::Http(_)));
    }
}