gfeh-http 0.1.2

The plain-HTTP view of gfeh: per-file public exposure with ranges and conditional requests
Documentation
//! The HTTP view driven by curl, against the server `gfehd` actually serves.
//!
//! Every other `third_party_conformance.rs` in this tree points at an *emulator*. This one
//! points at the view: the same listener a daemon binds, over a real store. An emulator
//! that satisfies a real client says nothing about the server users will meet, and every
//! defect this tier has found was in code somebody had already tested.
//!
//! curl is the right reader here because this view's entire contract is HTTP. There is no
//! `gfeh` semantics to misunderstand -- there is a status code, a `Content-Range`, an
//! `ETag`, a `Content-Disposition`, and whether a `HEAD` carries a body. curl shares none
//! of our assumptions about any of it, and it is the client every `curl -O` in a README
//! will actually be.
//!
//! These run as ordinary tests under `make test`. They need podman, which `make test`
//! therefore requires: a check that only runs when somebody remembers to ask for it is a
//! check that stops running.

use gfeh_core::{Disposition, NodeKind, NodeRef, ObjectStore, OpCtx};
use gfeh_http::{Exposed, Exposures, HttpView, StaticExposures};
use gfeh_store::MemStore;
use std::process::Command;
use std::sync::Arc;

/// Pinned by default, overridable by environment.
///
/// Pinned rather than floating: a client that silently updates turns this tier into a
/// source of failures that have nothing to do with the change being tested.
fn curl_image() -> String {
    std::env::var("GFEH_CURL_IMAGE")
        .unwrap_or_else(|_| "docker.io/curlimages/curl:latest".to_string())
}

/// One curl invocation against the host loopback.
struct Curl {
    status: String,
    headers: String,
    body: String,
}

fn curl(args: &[&str]) -> Curl {
    let output = Command::new("podman")
        .args(["run", "--rm", "--network=host"])
        .arg(curl_image())
        // `-D -` puts the response headers on stdout ahead of the body, and the status is
        // appended last, so all three can be read from one stream without a shared volume.
        .args([
            "--silent",
            "--show-error",
            "-D",
            "-",
            "--write-out",
            "\n%{http_code}",
        ])
        .args(args)
        .output()
        .expect("podman runs");
    assert!(
        output.status.success(),
        "curl {args:?} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let combined = String::from_utf8_lossy(&output.stdout).to_string();
    let (rest, status) = combined
        .rsplit_once('\n')
        .expect("curl writes the status last");
    let (headers, body) = match rest.rsplit_once("\r\n\r\n") {
        Some((headers, body)) => (headers.to_string(), body.to_string()),
        None => (rest.to_string(), String::new()),
    };

    Curl {
        status: status.to_string(),
        headers,
        body,
    }
}

impl Curl {
    /// The value of a response header, matched case-insensitively as HTTP requires.
    fn header(&self, name: &str) -> Option<String> {
        self.headers.lines().find_map(|line| {
            let (key, value) = line.split_once(':')?;
            key.eq_ignore_ascii_case(name)
                .then(|| value.trim().to_string())
        })
    }
}

/// A view over a store holding one published file.
async fn fixture(name: &str, body: &[u8], token: &str) -> HttpView {
    let store = Arc::new(MemStore::new());
    let exposures = Arc::new(StaticExposures::new());

    let cx = OpCtx::system("third-party");
    let mut handle = store
        .create(
            &cx,
            &store.root(),
            name,
            NodeKind::File,
            Disposition::CreateNew,
        )
        .await
        .expect("create");
    if !body.is_empty() {
        handle
            .write_at(0, bytes::Bytes::copy_from_slice(body))
            .await
            .expect("write");
    }
    let meta = handle.close().await.expect("close");

    exposures.publish(
        token,
        Exposed {
            node: NodeRef::Id(store.partition(), meta.id),
            filename: None,
            enabled: true,
        },
    );

    HttpView::builder(
        Arc::clone(&store) as Arc<dyn ObjectStore>,
        Arc::clone(&exposures) as Arc<dyn Exposures>,
    )
    .start()
    .await
    .expect("bind")
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_downloads_a_published_file() {
    let view = fixture("report.pdf", b"%PDF-1.7 body", "tok").await;
    let response = curl(&[&view.url_for("tok")]);

    assert_eq!(response.status, "200", "{}", response.headers);
    assert_eq!(
        response.body, "%PDF-1.7 body",
        "the wrong bytes were served"
    );
    assert_eq!(
        response.header("content-length").as_deref(),
        Some("13"),
        "a wrong Content-Length truncates or hangs a download: {}",
        response.headers
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_reads_an_http_date_from_last_modified() {
    // The S3 defect, checked here by a client that parses the header rather than by our
    // own formatter: ISO 8601 is not an HTTP-date, and a strict client rejects the whole
    // response over it.
    let view = fixture("report.pdf", b"body", "tok").await;
    let response = curl(&["--head", &view.url_for("tok")]);

    let modified = response
        .header("last-modified")
        .unwrap_or_else(|| panic!("no Last-Modified: {}", response.headers));

    // `Thu, 01 Jan 1970 00:00:00 GMT`: a day name, a comma, and GMT at the end. ISO 8601
    // is `1970-01-01T00:00:00Z`, so the hyphen is what tells them apart -- checking for a
    // `T` would reject `Thu` and `Tue`.
    assert!(
        modified.ends_with(" GMT"),
        "Last-Modified does not end in GMT: {modified}"
    );
    assert!(
        modified.contains(", "),
        "Last-Modified has no day name: {modified}"
    );
    assert!(
        !modified.contains('-'),
        "Last-Modified looks like ISO 8601: {modified}"
    );

    // curl's own conditional machinery, driven off the date it just parsed out of the
    // response. `-z` is what every `curl -z` in a cron job sends, and it is a stronger
    // check than re-sending the header ourselves would be: curl parses our HTTP-date into
    // its own time type and formats it again on the way out, so a date this server
    // produced but could not have produced correctly fails here rather than at some
    // client six months from now.
    let conditional = curl(&[
        "--output",
        "/dev/null",
        "--write-out",
        "%{http_code}",
        "--time-cond",
        &modified,
        &view.url_for("tok"),
    ]);
    assert_eq!(
        conditional.status, "304",
        "an If-Modified-Since carrying the object's own date must not re-send it: {}",
        conditional.headers
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_resumes_with_a_range_request() {
    // What every resumed download and every video player does. A 200 with the right bytes
    // still tells the client its range was ignored, so the status matters as much as the
    // body.
    let view = fixture("data.bin", b"0123456789", "tok").await;
    let response = curl(&["--range", "3-6", &view.url_for("tok")]);

    assert_eq!(response.status, "206", "{}", response.headers);
    assert_eq!(response.body, "3456", "the served range is wrong");
    assert_eq!(
        response.header("content-range").as_deref(),
        Some("bytes 3-6/10"),
        "{}",
        response.headers
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_is_told_the_length_when_its_range_is_unsatisfiable() {
    let view = fixture("data.bin", b"short", "tok").await;
    let response = curl(&["--range", "500-600", &view.url_for("tok")]);

    assert_eq!(response.status, "416", "{}", response.headers);
    // Without `bytes */len` the client has no way to learn what it should have asked for.
    assert_eq!(
        response.header("content-range").as_deref(),
        Some("bytes */5"),
        "{}",
        response.headers
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_revalidates_with_the_etag_it_was_given() {
    let view = fixture("report.pdf", b"body", "tok").await;
    let first = curl(&[&view.url_for("tok")]);
    let etag = first
        .header("etag")
        .unwrap_or_else(|| panic!("no ETag: {}", first.headers));

    let second = curl(&[
        "--header",
        &format!("If-None-Match: {etag}"),
        &view.url_for("tok"),
    ]);
    assert_eq!(second.status, "304", "{}", second.headers);
    assert!(
        second.body.is_empty(),
        "a 304 carried a body: {:?}",
        second.body
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_saves_the_file_under_the_name_it_was_told() {
    // `--remote-header-name` makes curl name the file from `Content-Disposition`, which is
    // the only reason that header exists. A malformed one leaves the download named after
    // the token.
    let view = fixture("report.pdf", b"body", "tok").await;
    let out = Command::new("podman")
        .args(["run", "--rm", "--network=host", "--entrypoint", "sh"])
        .arg(curl_image())
        .args([
            "-c",
            &format!(
                "cd /tmp && curl --silent --show-error --remote-name --remote-header-name {} \
                 && ls",
                view.url_for("tok")
            ),
        ])
        .output()
        .expect("podman runs");
    assert!(
        out.status.success(),
        "curl failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("report.pdf"),
        "the client did not name the download from Content-Disposition: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_gets_a_404_for_a_token_that_names_nothing() {
    let view = fixture("report.pdf", b"body", "tok").await;
    let response = curl(&[&format!("{}/f/not-a-token", view.base_url())]);
    assert_eq!(response.status, "404", "{}", response.headers);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_finds_no_surface_but_the_token() {
    // No listing, no index, no health route: the only address this view answers is the one
    // somebody deliberately minted.
    let view = fixture("report.pdf", b"body", "tok").await;
    for path in ["/", "/f/", "/report.pdf", "/health"] {
        let response = curl(&[
            "--output",
            "/dev/null",
            "--write-out",
            "%{http_code}",
            &format!("{}{path}", view.base_url()),
        ]);
        assert!(
            !response.status.starts_with('2'),
            "{path} was served with {}",
            response.status
        );
    }
}