gfeh-http 0.1.2

The plain-HTTP view of gfeh: per-file public exposure with ranges and conditional requests
Documentation
//! The HTTP view over a real socket, read by a real HTTP client.
//!
//! Everything asserted here is a header, because headers are the entire contract of
//! this view. A `Range` that resolves wrongly, a `Last-Modified` in the wrong format,
//! or a 416 without `Content-Range` are all failures no amount of correct bytes makes
//! up for -- and all three are invisible to a test that calls a Rust function instead
//! of speaking HTTP.

use bytes::Bytes;
use gfeh_core::{Disposition, MetaPatch, NodeKind, NodeRef, ObjectStore, OpCtx};
use gfeh_http::{Exposed, HttpView, StaticExposures};
use gfeh_store::MemStore;
use reqwest::StatusCode;
use reqwest::header;
use std::sync::Arc;

/// A store holding one file, published under `tok`.
async fn fixture(body: &'static [u8]) -> (HttpView, StaticExposures, Arc<MemStore>, NodeRef) {
    let store = Arc::new(MemStore::new());
    let cx = OpCtx::system("test");

    let mut handle = store
        .create(
            &cx,
            &store.root(),
            "report.pdf",
            NodeKind::File,
            Disposition::CreateNew,
        )
        .await
        .expect("create");
    handle
        .write_at(0, Bytes::from_static(body))
        .await
        .expect("write");
    let meta = handle.close().await.expect("close");
    let node = NodeRef::Id(store.partition(), meta.id);

    let exposures = StaticExposures::new().with(
        "tok",
        Exposed {
            node: node.clone(),
            filename: None,
            enabled: true,
        },
    );
    let view = HttpView::builder(
        Arc::clone(&store) as Arc<dyn ObjectStore>,
        Arc::new(exposures.clone()),
    )
    .start()
    .await
    .expect("bind");

    (view, exposures, store, node)
}

fn header_of(response: &reqwest::Response, name: header::HeaderName) -> String {
    response
        .headers()
        .get(name)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default()
        .to_string()
}

#[tokio::test]
async fn a_published_file_is_served_whole() {
    let (view, ..) = fixture(b"0123456789").await;
    let response = reqwest::get(view.url_for("tok")).await.expect("request");

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(header_of(&response, header::CONTENT_LENGTH), "10");
    assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
    assert!(
        header_of(&response, header::CONTENT_DISPOSITION).contains("report.pdf"),
        "the filename must reach the browser"
    );
    assert_eq!(response.text().await.expect("body"), "0123456789");
}

#[tokio::test]
async fn last_modified_is_an_http_date_rather_than_iso_8601() {
    // The defect that made an S3 object visible in a listing and unreadable by the AWS
    // SDK for Go. It is one shared helper away from happening again in this view.
    let (view, ..) = fixture(b"body").await;
    let response = reqwest::get(view.url_for("tok")).await.expect("request");

    let modified = header_of(&response, header::LAST_MODIFIED);
    assert!(modified.ends_with(" GMT"), "{modified}");
    assert!(!modified.contains('Z'), "{modified}");
    assert!(!modified.contains('-'), "{modified}");
}

#[tokio::test]
async fn an_explicit_range_yields_partial_content() {
    let (view, ..) = fixture(b"0123456789").await;
    let response = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::RANGE, "bytes=2-5")
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes 2-5/10");
    assert_eq!(header_of(&response, header::CONTENT_LENGTH), "4");
    assert_eq!(response.text().await.expect("body"), "2345");
}

#[tokio::test]
async fn a_suffix_range_serves_the_end_of_the_object() {
    // `bytes=-3` is the last three bytes. Serving bytes 3 onwards instead is the
    // classic misreading, and it is what hands a media player the wrong part of a file.
    let (view, ..) = fixture(b"0123456789").await;
    let response = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::RANGE, "bytes=-3")
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes 7-9/10");
    assert_eq!(response.text().await.expect("body"), "789");
}

#[tokio::test]
async fn an_open_ended_range_runs_to_the_end() {
    let (view, ..) = fixture(b"0123456789").await;
    let response = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::RANGE, "bytes=7-")
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
    assert_eq!(response.text().await.expect("body"), "789");
}

#[tokio::test]
async fn an_unsatisfiable_range_says_how_long_the_object_is() {
    // Without `Content-Range: bytes */len` a client that asked for too much has no way
    // to correct itself, and retries the same doomed request.
    let (view, ..) = fixture(b"short").await;
    let response = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::RANGE, "bytes=500-600")
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
    assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes */5");
}

#[tokio::test]
async fn a_conditional_request_is_answered_without_the_body() {
    let (view, ..) = fixture(b"cache me").await;
    let first = reqwest::get(view.url_for("tok")).await.expect("request");
    let etag = header_of(&first, header::ETAG);
    assert!(etag.starts_with('"') && etag.ends_with('"'), "{etag}");

    let second = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::IF_NONE_MATCH, &etag)
        .send()
        .await
        .expect("request");

    assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
    assert_eq!(header_of(&second, header::ETAG), etag);
    assert!(second.bytes().await.expect("body").is_empty());
}

#[tokio::test]
async fn a_stale_validator_gets_the_object() {
    let (view, ..) = fixture(b"cache me").await;
    let response = reqwest::Client::new()
        .get(view.url_for("tok"))
        .header(header::IF_NONE_MATCH, "\"not-the-current-tag\"")
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.text().await.expect("body"), "cache me");
}

#[tokio::test]
async fn a_head_request_carries_the_headers_and_no_body() {
    let (view, ..) = fixture(b"0123456789").await;
    let response = reqwest::Client::new()
        .head(view.url_for("tok"))
        .send()
        .await
        .expect("request");

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(header_of(&response, header::CONTENT_LENGTH), "10");
    assert!(response.bytes().await.expect("body").is_empty());
}

#[tokio::test]
async fn the_content_type_comes_from_the_object() {
    let (view, _, store, node) = fixture(b"{}").await;
    store
        .set_meta(
            &OpCtx::system("test"),
            &node,
            MetaPatch {
                mime: Some("application/json".into()),
                ..MetaPatch::default()
            },
        )
        .await
        .expect("set mime");

    let response = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(
        header_of(&response, header::CONTENT_TYPE),
        "application/json"
    );
}

#[tokio::test]
async fn an_object_with_no_declared_type_is_served_as_octet_stream() {
    // Deliberately not sniffed. Guessing a type from content is how a text file
    // somebody uploaded becomes an HTML page that runs in a visitor's browser.
    let (view, ..) = fixture(b"<script>alert(1)</script>").await;
    let response = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(
        header_of(&response, header::CONTENT_TYPE),
        "application/octet-stream"
    );
}

#[tokio::test]
async fn an_unknown_token_and_a_revoked_one_are_indistinguishable() {
    // Answering 403 for a revoked link would confirm the token is real, which is
    // exactly what the holder of a withdrawn link should not learn.
    let (view, exposures, ..) = fixture(b"secret").await;

    let unknown = reqwest::get(view.url_for("no-such-token"))
        .await
        .expect("request");
    assert_eq!(unknown.status(), StatusCode::NOT_FOUND);

    exposures.set_enabled("tok", false);
    let revoked = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(revoked.status(), StatusCode::NOT_FOUND);

    exposures.set_enabled("tok", true);
    let restored = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(restored.status(), StatusCode::OK);
}

#[tokio::test]
async fn a_link_survives_a_rename_of_the_file_it_names() {
    // The reason a token is not a path: a URL already in circulation must keep working.
    let (view, _, store, node) = fixture(b"stable").await;
    store
        .rename(
            &OpCtx::system("test"),
            &node,
            &store.root(),
            "renamed.pdf",
            false,
        )
        .await
        .expect("rename");

    let response = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(response.status(), StatusCode::OK);
    assert!(
        header_of(&response, header::CONTENT_DISPOSITION).contains("renamed.pdf"),
        "the advertised filename should follow the rename"
    );
}

#[tokio::test]
async fn a_hostile_filename_cannot_inject_a_header() {
    // A name is user-controlled. A quote or a newline in `Content-Disposition` would be
    // a header injection into every response this daemon serves.
    let store = Arc::new(MemStore::new());
    let cx = OpCtx::system("test");
    let handle = store
        .create(
            &cx,
            &store.root(),
            "a.txt",
            NodeKind::File,
            Disposition::CreateNew,
        )
        .await
        .expect("create");
    let meta = handle.close().await.expect("close");

    let exposures = StaticExposures::new().with(
        "tok",
        Exposed {
            node: NodeRef::Id(store.partition(), meta.id),
            filename: Some("evil\"\r\nX-Injected: yes".into()),
            enabled: true,
        },
    );
    let view = HttpView::builder(store, Arc::new(exposures))
        .start()
        .await
        .expect("bind");

    let response = reqwest::get(view.url_for("tok")).await.expect("request");
    assert_eq!(response.status(), StatusCode::OK);
    assert!(
        response.headers().get("x-injected").is_none(),
        "header injected"
    );
    let disposition = header_of(&response, header::CONTENT_DISPOSITION);
    assert_eq!(disposition, "inline; filename=\"evilX-Injected: yes\"");
}

#[tokio::test]
async fn an_empty_file_is_served_as_an_empty_body_rather_than_an_error() {
    let (view, ..) = fixture(b"").await;
    let response = reqwest::get(view.url_for("tok")).await.expect("request");

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(header_of(&response, header::CONTENT_LENGTH), "0");
    assert!(response.bytes().await.expect("body").is_empty());
}

#[tokio::test]
async fn a_large_object_streams_rather_than_buffering() {
    // Four megabytes is past any plausible buffer, so a body that arrives intact is
    // evidence the response was streamed through the handle rather than collected.
    let payload = vec![b'x'; 4 * 1024 * 1024];
    let store = Arc::new(MemStore::new());
    let cx = OpCtx::system("test");
    let mut handle = store
        .create(
            &cx,
            &store.root(),
            "big.bin",
            NodeKind::File,
            Disposition::CreateNew,
        )
        .await
        .expect("create");
    handle
        .write_at(0, Bytes::from(payload.clone()))
        .await
        .expect("write");
    let meta = handle.close().await.expect("close");

    let exposures = StaticExposures::new().with(
        "tok",
        Exposed {
            node: NodeRef::Id(store.partition(), meta.id),
            filename: None,
            enabled: true,
        },
    );
    let view = HttpView::builder(store, Arc::new(exposures))
        .start()
        .await
        .expect("bind");

    let body = reqwest::get(view.url_for("tok"))
        .await
        .expect("request")
        .bytes()
        .await
        .expect("body");
    assert_eq!(body.len(), payload.len());
    assert_eq!(&body[..], &payload[..]);
}