use async_trait::async_trait;
use futures::future::BoxFuture;
use gfeh_core::{Disposition, Error, NodeKind, NodeRef, ObjectStore, OpCtx, PartitionId, Result};
use std::fmt::Debug;
use std::future::Future;
use std::sync::Arc;
use crate::{Exposed, Exposures, HttpView, StaticExposures};
pub struct Target {
pub store: Arc<dyn ObjectStore>,
pub root: NodeRef,
pub partition: PartitionId,
}
#[async_trait]
pub trait Backend: Send + Sync {
fn name(&self) -> String;
async fn fresh(&self) -> Result<Target>;
}
pub struct Served {
view: HttpView,
exposures: Arc<StaticExposures>,
target: Target,
client: reqwest::Client,
}
impl Served {
async fn start(target: Target) -> Result<Self> {
let exposures = Arc::new(StaticExposures::new());
let view = HttpView::builder(
Arc::clone(&target.store),
Arc::clone(&exposures) as Arc<dyn Exposures>,
)
.start()
.await
.map_err(|e| Error::Storage(format!("binding the view: {e}")))?;
Ok(Self {
view,
exposures,
target,
client: reqwest::Client::new(),
})
}
async fn publish(&self, name: &str, body: &[u8], token: &str) -> Result<()> {
let cx = OpCtx::system("conformance");
let mut handle = self
.target
.store
.create(
&cx,
&self.target.root,
name,
NodeKind::File,
Disposition::CreateNew,
)
.await?;
if !body.is_empty() {
handle
.write_at(0, bytes::Bytes::copy_from_slice(body))
.await?;
}
let meta = handle.close().await?;
self.exposures.publish(
token,
Exposed {
node: NodeRef::Id(self.target.partition, meta.id),
filename: None,
enabled: true,
},
);
Ok(())
}
async fn get(&self, token: &str, headers: &[(&str, &str)]) -> Result<reqwest::Response> {
let mut request = self.client.get(self.view.url_for(token));
for (name, value) in headers {
request = request.header(*name, *value);
}
request
.send()
.await
.map_err(|e| Error::Storage(format!("request: {e}")))
}
}
pub struct Case {
name: &'static str,
body: Box<dyn Fn(Served) -> BoxFuture<'static, Result<()>> + Send + Sync>,
}
impl Case {
fn new<F, Fut>(name: &'static str, body: F) -> Self
where
F: Fn(Served) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
Self {
name,
body: Box::new(move |served| Box::pin(body(served))),
}
}
#[must_use]
pub fn name(&self) -> &'static str {
self.name
}
}
impl Debug for Case {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Case").field("name", &self.name).finish()
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub case: &'static str,
pub failure: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Report {
pub backend: String,
pub outcomes: Vec<Outcome>,
}
impl Report {
#[must_use]
pub fn is_conformant(&self) -> bool {
self.outcomes.iter().all(|o| o.failure.is_none())
}
#[must_use]
pub fn passed(&self) -> usize {
self.outcomes.iter().filter(|o| o.failure.is_none()).count()
}
pub fn failures(&self) -> impl Iterator<Item = &Outcome> {
self.outcomes.iter().filter(|o| o.failure.is_some())
}
#[must_use]
pub fn summary(&self) -> String {
let mut out = format!(
"{}: {}/{} conformance cases passed",
self.backend,
self.passed(),
self.outcomes.len()
);
for outcome in self.failures() {
let reason = outcome.failure.as_deref().unwrap_or("unknown");
out.push_str(&format!("\n FAIL {}: {reason}", outcome.case));
}
out
}
}
pub async fn run<B: Backend>(backend: &B) -> Report {
let mut outcomes = Vec::new();
for case in cases() {
let failure = match backend.fresh().await {
Ok(target) => match Served::start(target).await {
Ok(served) => (case.body)(served).await.err().map(|e| e.to_string()),
Err(e) => Some(format!("the view could not be served: {e}")),
},
Err(e) => Some(format!("backend could not provide a fresh store: {e}")),
};
outcomes.push(Outcome {
case: case.name(),
failure,
});
}
Report {
backend: backend.name(),
outcomes,
}
}
fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
if condition {
Ok(())
} else {
Err(Error::Other(message.into()))
}
}
fn ensure_eq<T: PartialEq + Debug>(actual: T, expected: T, what: &str) -> Result<()> {
ensure(
actual == expected,
format!("{what}: expected {expected:?}, got {actual:?}"),
)
}
fn header(response: &reqwest::Response, name: &str) -> Option<String> {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
#[must_use]
pub fn cases() -> Vec<Case> {
vec![
Case::new("a_published_file_is_served_whole", |s| async move {
s.publish("report.pdf", b"%PDF-1.7 body", "tok").await?;
let response = s.get("tok", &[]).await?;
ensure_eq(response.status().as_u16(), 200, "status")?;
ensure_eq(
header(&response, "content-length"),
Some("13".into()),
"content-length",
)?;
let body = response.text().await.unwrap_or_default();
ensure_eq(body, "%PDF-1.7 body".to_string(), "body")
}),
Case::new("an_unknown_token_is_not_found", |s| async move {
ensure_eq(s.get("nope", &[]).await?.status().as_u16(), 404, "status")
}),
Case::new(
"a_disabled_link_is_indistinguishable_from_an_unknown_one",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
s.exposures.publish(
"tok",
Exposed {
enabled: false,
..s.exposures.resolve("tok").unwrap_or(Exposed {
node: s.target.root.clone(),
filename: None,
enabled: false,
})
},
);
let disabled = s.get("tok", &[]).await?;
let unknown = s.get("nope", &[]).await?;
ensure_eq(disabled.status(), unknown.status(), "status")
},
),
Case::new("last_modified_is_an_http_date", |s| async move {
s.publish("report.pdf", b"body", "tok").await?;
let response = s.get("tok", &[]).await?;
let value = header(&response, "last-modified")
.ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
ensure(value.ends_with(" GMT"), format!("Last-Modified: {value}"))?;
ensure(value.contains(", "), format!("Last-Modified: {value}"))?;
ensure(!value.contains('-'), format!("Last-Modified: {value}"))
}),
Case::new(
"an_etag_answers_a_conditional_request_without_the_bytes",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
let first = s.get("tok", &[]).await?;
let etag =
header(&first, "etag").ok_or_else(|| Error::Other("no ETag header".into()))?;
let second = s.get("tok", &[("If-None-Match", &etag)]).await?;
ensure_eq(second.status().as_u16(), 304, "status")?;
ensure_eq(
second.text().await.unwrap_or_default(),
String::new(),
"a 304 carried a body",
)
},
),
Case::new(
"a_date_answers_a_conditional_request_without_the_bytes",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
let first = s.get("tok", &[]).await?;
let modified = header(&first, "last-modified")
.ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
let second = s.get("tok", &[("If-Modified-Since", &modified)]).await?;
ensure_eq(second.status().as_u16(), 304, "status")?;
ensure_eq(
second.text().await.unwrap_or_default(),
String::new(),
"a 304 carried a body",
)?;
let stale = s
.get(
"tok",
&[("If-Modified-Since", "Wed, 31 Dec 1969 00:00:00 GMT")],
)
.await?;
ensure_eq(stale.status().as_u16(), 200, "status for an older date")
},
),
Case::new(
"an_unparseable_date_is_answered_with_the_object",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
for value in ["2023-11-14T22:13:20Z", "yesterday", ""] {
let response = s.get("tok", &[("If-Modified-Since", value)]).await?;
ensure_eq(
response.status().as_u16(),
200,
&format!("status for {value:?}"),
)?;
}
Ok(())
},
),
Case::new(
"an_entity_tag_is_believed_over_a_date_when_both_are_sent",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
let first = s.get("tok", &[]).await?;
let modified = header(&first, "last-modified")
.ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
let response = s
.get(
"tok",
&[
("If-None-Match", "\"not-the-tag\""),
("If-Modified-Since", &modified),
],
)
.await?;
ensure_eq(
response.status().as_u16(),
200,
"a stale tag with a current date must send the object",
)
},
),
Case::new("a_range_serves_the_bytes_it_asked_for", |s| async move {
s.publish("report.pdf", b"0123456789", "tok").await?;
let response = s.get("tok", &[("Range", "bytes=2-5")]).await?;
ensure_eq(response.status().as_u16(), 206, "status")?;
ensure_eq(
header(&response, "content-range"),
Some("bytes 2-5/10".into()),
"content-range",
)?;
ensure_eq(
response.text().await.unwrap_or_default(),
"2345".to_string(),
"body",
)
}),
Case::new(
"a_suffix_range_serves_the_end_of_the_file",
|s| async move {
s.publish("report.pdf", b"0123456789", "tok").await?;
let response = s.get("tok", &[("Range", "bytes=-3")]).await?;
ensure_eq(response.status().as_u16(), 206, "status")?;
ensure_eq(
response.text().await.unwrap_or_default(),
"789".to_string(),
"body",
)
},
),
Case::new("an_open_ended_range_runs_to_the_end", |s| async move {
s.publish("report.pdf", b"0123456789", "tok").await?;
let response = s.get("tok", &[("Range", "bytes=7-")]).await?;
ensure_eq(response.status().as_u16(), 206, "status")?;
ensure_eq(
response.text().await.unwrap_or_default(),
"789".to_string(),
"body",
)
}),
Case::new(
"a_range_past_the_end_says_how_much_there_is",
|s| async move {
s.publish("report.pdf", b"0123456789", "tok").await?;
let response = s.get("tok", &[("Range", "bytes=50-60")]).await?;
ensure_eq(response.status().as_u16(), 416, "status")?;
ensure_eq(
header(&response, "content-range"),
Some("bytes */10".into()),
"content-range",
)
},
),
Case::new("an_empty_file_is_served_as_an_empty_body", |s| async move {
s.publish("empty.txt", b"", "tok").await?;
let response = s.get("tok", &[]).await?;
ensure_eq(response.status().as_u16(), 200, "status")?;
ensure_eq(
header(&response, "content-length"),
Some("0".into()),
"content-length",
)
}),
Case::new(
"a_content_disposition_carries_the_advertised_name",
|s| async move {
s.publish("report.pdf", b"body", "tok").await?;
let response = s.get("tok", &[]).await?;
let value = header(&response, "content-disposition")
.ok_or_else(|| Error::Other("no Content-Disposition header".into()))?;
ensure(
value.contains("report.pdf"),
format!("Content-Disposition: {value}"),
)
},
),
Case::new("a_filename_cannot_inject_a_header", |s| async move {
s.publish("ordinary.txt", b"body", "tok").await?;
let exposed = s
.exposures
.resolve("tok")
.ok_or_else(|| Error::Other("the token vanished".into()))?;
s.exposures.publish(
"tok",
Exposed {
filename: Some("a\r\nX-Injected: yes\r\n.txt".into()),
..exposed
},
);
let response = s.get("tok", &[]).await?;
ensure(
response.headers().get("x-injected").is_none(),
"a filename injected a header",
)
}),
Case::new(
"a_link_survives_a_rename_of_the_file_it_names",
|s| async move {
s.publish("before.pdf", b"body", "tok").await?;
let exposed = s
.exposures
.resolve("tok")
.ok_or_else(|| Error::Other("the token vanished".into()))?;
s.target
.store
.rename(
&OpCtx::system("conformance"),
&exposed.node,
&s.target.root,
"after.pdf",
false,
)
.await?;
let response = s.get("tok", &[]).await?;
ensure_eq(response.status().as_u16(), 200, "status after a rename")
},
),
Case::new(
"a_head_answers_the_headers_without_the_body",
|s| async move {
s.publish("report.pdf", b"0123456789", "tok").await?;
let response = s
.client
.head(s.view.url_for("tok"))
.send()
.await
.map_err(|e| Error::Storage(format!("request: {e}")))?;
ensure_eq(response.status().as_u16(), 200, "status")?;
ensure_eq(
header(&response, "content-length"),
Some("10".into()),
"content-length",
)?;
ensure_eq(
response.text().await.unwrap_or_default(),
String::new(),
"a HEAD carried a body",
)
},
),
Case::new("nothing_but_a_token_names_a_file", |s| async move {
s.publish("report.pdf", b"body", "tok").await?;
for path in ["/", "/f/", "/f/../report.pdf", "/report.pdf", "/health"] {
let response = s
.client
.get(format!("{}{path}", s.view.base_url()))
.send()
.await
.map_err(|e| Error::Storage(format!("request: {e}")))?;
ensure(
!response.status().is_success(),
format!("{path} was served with {}", response.status()),
)?;
}
Ok(())
}),
]
}