1use async_trait::async_trait;
54use futures::future::BoxFuture;
55use gfeh_core::{Disposition, Error, NodeKind, NodeRef, ObjectStore, OpCtx, PartitionId, Result};
56use std::fmt::Debug;
57use std::future::Future;
58use std::sync::Arc;
59
60use crate::{Exposed, Exposures, HttpView, StaticExposures};
61
62pub struct Target {
68 pub store: Arc<dyn ObjectStore>,
70 pub root: NodeRef,
72 pub partition: PartitionId,
74}
75
76#[async_trait]
78pub trait Backend: Send + Sync {
79 fn name(&self) -> String;
81
82 async fn fresh(&self) -> Result<Target>;
89}
90
91pub struct Served {
97 view: HttpView,
98 exposures: Arc<StaticExposures>,
99 target: Target,
100 client: reqwest::Client,
101}
102
103impl Served {
104 async fn start(target: Target) -> Result<Self> {
105 let exposures = Arc::new(StaticExposures::new());
106 let view = HttpView::builder(
107 Arc::clone(&target.store),
108 Arc::clone(&exposures) as Arc<dyn Exposures>,
109 )
110 .start()
111 .await
112 .map_err(|e| Error::Storage(format!("binding the view: {e}")))?;
113 Ok(Self {
114 view,
115 exposures,
116 target,
117 client: reqwest::Client::new(),
118 })
119 }
120
121 async fn publish(&self, name: &str, body: &[u8], token: &str) -> Result<()> {
123 let cx = OpCtx::system("conformance");
124 let mut handle = self
125 .target
126 .store
127 .create(
128 &cx,
129 &self.target.root,
130 name,
131 NodeKind::File,
132 Disposition::CreateNew,
133 )
134 .await?;
135 if !body.is_empty() {
136 handle
137 .write_at(0, bytes::Bytes::copy_from_slice(body))
138 .await?;
139 }
140 let meta = handle.close().await?;
141
142 self.exposures.publish(
143 token,
144 Exposed {
145 node: NodeRef::Id(self.target.partition, meta.id),
146 filename: None,
147 enabled: true,
148 },
149 );
150 Ok(())
151 }
152
153 async fn get(&self, token: &str, headers: &[(&str, &str)]) -> Result<reqwest::Response> {
155 let mut request = self.client.get(self.view.url_for(token));
156 for (name, value) in headers {
157 request = request.header(*name, *value);
158 }
159 request
160 .send()
161 .await
162 .map_err(|e| Error::Storage(format!("request: {e}")))
163 }
164}
165
166pub struct Case {
172 name: &'static str,
173 body: Box<dyn Fn(Served) -> BoxFuture<'static, Result<()>> + Send + Sync>,
174}
175
176impl Case {
177 fn new<F, Fut>(name: &'static str, body: F) -> Self
178 where
179 F: Fn(Served) -> Fut + Send + Sync + 'static,
180 Fut: Future<Output = Result<()>> + Send + 'static,
181 {
182 Self {
183 name,
184 body: Box::new(move |served| Box::pin(body(served))),
185 }
186 }
187
188 #[must_use]
190 pub fn name(&self) -> &'static str {
191 self.name
192 }
193}
194
195impl Debug for Case {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("Case").field("name", &self.name).finish()
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct Outcome {
204 pub case: &'static str,
206 pub failure: Option<String>,
208}
209
210#[derive(Debug, Clone)]
212pub struct Report {
213 pub backend: String,
215 pub outcomes: Vec<Outcome>,
217}
218
219impl Report {
220 #[must_use]
222 pub fn is_conformant(&self) -> bool {
223 self.outcomes.iter().all(|o| o.failure.is_none())
224 }
225
226 #[must_use]
228 pub fn passed(&self) -> usize {
229 self.outcomes.iter().filter(|o| o.failure.is_none()).count()
230 }
231
232 pub fn failures(&self) -> impl Iterator<Item = &Outcome> {
234 self.outcomes.iter().filter(|o| o.failure.is_some())
235 }
236
237 #[must_use]
239 pub fn summary(&self) -> String {
240 let mut out = format!(
241 "{}: {}/{} conformance cases passed",
242 self.backend,
243 self.passed(),
244 self.outcomes.len()
245 );
246 for outcome in self.failures() {
247 let reason = outcome.failure.as_deref().unwrap_or("unknown");
248 out.push_str(&format!("\n FAIL {}: {reason}", outcome.case));
249 }
250 out
251 }
252}
253
254pub async fn run<B: Backend>(backend: &B) -> Report {
259 let mut outcomes = Vec::new();
260 for case in cases() {
261 let failure = match backend.fresh().await {
262 Ok(target) => match Served::start(target).await {
263 Ok(served) => (case.body)(served).await.err().map(|e| e.to_string()),
264 Err(e) => Some(format!("the view could not be served: {e}")),
265 },
266 Err(e) => Some(format!("backend could not provide a fresh store: {e}")),
267 };
268 outcomes.push(Outcome {
269 case: case.name(),
270 failure,
271 });
272 }
273 Report {
274 backend: backend.name(),
275 outcomes,
276 }
277}
278
279fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
281 if condition {
282 Ok(())
283 } else {
284 Err(Error::Other(message.into()))
285 }
286}
287
288fn ensure_eq<T: PartialEq + Debug>(actual: T, expected: T, what: &str) -> Result<()> {
290 ensure(
291 actual == expected,
292 format!("{what}: expected {expected:?}, got {actual:?}"),
293 )
294}
295
296fn header(response: &reqwest::Response, name: &str) -> Option<String> {
298 response
299 .headers()
300 .get(name)
301 .and_then(|v| v.to_str().ok())
302 .map(str::to_string)
303}
304
305#[must_use]
307pub fn cases() -> Vec<Case> {
308 vec![
309 Case::new("a_published_file_is_served_whole", |s| async move {
310 s.publish("report.pdf", b"%PDF-1.7 body", "tok").await?;
311 let response = s.get("tok", &[]).await?;
312 ensure_eq(response.status().as_u16(), 200, "status")?;
313 ensure_eq(
314 header(&response, "content-length"),
315 Some("13".into()),
316 "content-length",
317 )?;
318 let body = response.text().await.unwrap_or_default();
319 ensure_eq(body, "%PDF-1.7 body".to_string(), "body")
320 }),
321 Case::new("an_unknown_token_is_not_found", |s| async move {
322 ensure_eq(s.get("nope", &[]).await?.status().as_u16(), 404, "status")
323 }),
324 Case::new(
325 "a_disabled_link_is_indistinguishable_from_an_unknown_one",
326 |s| async move {
327 s.publish("report.pdf", b"body", "tok").await?;
330 s.exposures.publish(
331 "tok",
332 Exposed {
333 enabled: false,
334 ..s.exposures.resolve("tok").unwrap_or(Exposed {
335 node: s.target.root.clone(),
336 filename: None,
337 enabled: false,
338 })
339 },
340 );
341 let disabled = s.get("tok", &[]).await?;
342 let unknown = s.get("nope", &[]).await?;
343 ensure_eq(disabled.status(), unknown.status(), "status")
344 },
345 ),
346 Case::new("last_modified_is_an_http_date", |s| async move {
347 s.publish("report.pdf", b"body", "tok").await?;
350 let response = s.get("tok", &[]).await?;
351 let value = header(&response, "last-modified")
352 .ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
353 ensure(value.ends_with(" GMT"), format!("Last-Modified: {value}"))?;
357 ensure(value.contains(", "), format!("Last-Modified: {value}"))?;
358 ensure(!value.contains('-'), format!("Last-Modified: {value}"))
359 }),
360 Case::new(
361 "an_etag_answers_a_conditional_request_without_the_bytes",
362 |s| async move {
363 s.publish("report.pdf", b"body", "tok").await?;
364 let first = s.get("tok", &[]).await?;
365 let etag =
366 header(&first, "etag").ok_or_else(|| Error::Other("no ETag header".into()))?;
367
368 let second = s.get("tok", &[("If-None-Match", &etag)]).await?;
369 ensure_eq(second.status().as_u16(), 304, "status")?;
370 ensure_eq(
371 second.text().await.unwrap_or_default(),
372 String::new(),
373 "a 304 carried a body",
374 )
375 },
376 ),
377 Case::new(
378 "a_date_answers_a_conditional_request_without_the_bytes",
379 |s| async move {
380 s.publish("report.pdf", b"body", "tok").await?;
384 let first = s.get("tok", &[]).await?;
385 let modified = header(&first, "last-modified")
386 .ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
387
388 let second = s.get("tok", &[("If-Modified-Since", &modified)]).await?;
389 ensure_eq(second.status().as_u16(), 304, "status")?;
390 ensure_eq(
391 second.text().await.unwrap_or_default(),
392 String::new(),
393 "a 304 carried a body",
394 )?;
395
396 let stale = s
409 .get(
410 "tok",
411 &[("If-Modified-Since", "Wed, 31 Dec 1969 00:00:00 GMT")],
412 )
413 .await?;
414 ensure_eq(stale.status().as_u16(), 200, "status for an older date")
415 },
416 ),
417 Case::new(
418 "an_unparseable_date_is_answered_with_the_object",
419 |s| async move {
420 s.publish("report.pdf", b"body", "tok").await?;
425 for value in ["2023-11-14T22:13:20Z", "yesterday", ""] {
426 let response = s.get("tok", &[("If-Modified-Since", value)]).await?;
427 ensure_eq(
428 response.status().as_u16(),
429 200,
430 &format!("status for {value:?}"),
431 )?;
432 }
433 Ok(())
434 },
435 ),
436 Case::new(
437 "an_entity_tag_is_believed_over_a_date_when_both_are_sent",
438 |s| async move {
439 s.publish("report.pdf", b"body", "tok").await?;
444 let first = s.get("tok", &[]).await?;
445 let modified = header(&first, "last-modified")
446 .ok_or_else(|| Error::Other("no Last-Modified header".into()))?;
447
448 let response = s
449 .get(
450 "tok",
451 &[
452 ("If-None-Match", "\"not-the-tag\""),
453 ("If-Modified-Since", &modified),
454 ],
455 )
456 .await?;
457 ensure_eq(
458 response.status().as_u16(),
459 200,
460 "a stale tag with a current date must send the object",
461 )
462 },
463 ),
464 Case::new("a_range_serves_the_bytes_it_asked_for", |s| async move {
465 s.publish("report.pdf", b"0123456789", "tok").await?;
466 let response = s.get("tok", &[("Range", "bytes=2-5")]).await?;
467 ensure_eq(response.status().as_u16(), 206, "status")?;
468 ensure_eq(
469 header(&response, "content-range"),
470 Some("bytes 2-5/10".into()),
471 "content-range",
472 )?;
473 ensure_eq(
474 response.text().await.unwrap_or_default(),
475 "2345".to_string(),
476 "body",
477 )
478 }),
479 Case::new(
480 "a_suffix_range_serves_the_end_of_the_file",
481 |s| async move {
482 s.publish("report.pdf", b"0123456789", "tok").await?;
485 let response = s.get("tok", &[("Range", "bytes=-3")]).await?;
486 ensure_eq(response.status().as_u16(), 206, "status")?;
487 ensure_eq(
488 response.text().await.unwrap_or_default(),
489 "789".to_string(),
490 "body",
491 )
492 },
493 ),
494 Case::new("an_open_ended_range_runs_to_the_end", |s| async move {
495 s.publish("report.pdf", b"0123456789", "tok").await?;
496 let response = s.get("tok", &[("Range", "bytes=7-")]).await?;
497 ensure_eq(response.status().as_u16(), 206, "status")?;
498 ensure_eq(
499 response.text().await.unwrap_or_default(),
500 "789".to_string(),
501 "body",
502 )
503 }),
504 Case::new(
505 "a_range_past_the_end_says_how_much_there_is",
506 |s| async move {
507 s.publish("report.pdf", b"0123456789", "tok").await?;
510 let response = s.get("tok", &[("Range", "bytes=50-60")]).await?;
511 ensure_eq(response.status().as_u16(), 416, "status")?;
512 ensure_eq(
513 header(&response, "content-range"),
514 Some("bytes */10".into()),
515 "content-range",
516 )
517 },
518 ),
519 Case::new("an_empty_file_is_served_as_an_empty_body", |s| async move {
520 s.publish("empty.txt", b"", "tok").await?;
521 let response = s.get("tok", &[]).await?;
522 ensure_eq(response.status().as_u16(), 200, "status")?;
523 ensure_eq(
524 header(&response, "content-length"),
525 Some("0".into()),
526 "content-length",
527 )
528 }),
529 Case::new(
530 "a_content_disposition_carries_the_advertised_name",
531 |s| async move {
532 s.publish("report.pdf", b"body", "tok").await?;
533 let response = s.get("tok", &[]).await?;
534 let value = header(&response, "content-disposition")
535 .ok_or_else(|| Error::Other("no Content-Disposition header".into()))?;
536 ensure(
537 value.contains("report.pdf"),
538 format!("Content-Disposition: {value}"),
539 )
540 },
541 ),
542 Case::new("a_filename_cannot_inject_a_header", |s| async move {
543 s.publish("ordinary.txt", b"body", "tok").await?;
546 let exposed = s
547 .exposures
548 .resolve("tok")
549 .ok_or_else(|| Error::Other("the token vanished".into()))?;
550 s.exposures.publish(
551 "tok",
552 Exposed {
553 filename: Some("a\r\nX-Injected: yes\r\n.txt".into()),
554 ..exposed
555 },
556 );
557
558 let response = s.get("tok", &[]).await?;
559 ensure(
560 response.headers().get("x-injected").is_none(),
561 "a filename injected a header",
562 )
563 }),
564 Case::new(
565 "a_link_survives_a_rename_of_the_file_it_names",
566 |s| async move {
567 s.publish("before.pdf", b"body", "tok").await?;
570 let exposed = s
571 .exposures
572 .resolve("tok")
573 .ok_or_else(|| Error::Other("the token vanished".into()))?;
574 s.target
575 .store
576 .rename(
577 &OpCtx::system("conformance"),
578 &exposed.node,
579 &s.target.root,
580 "after.pdf",
581 false,
582 )
583 .await?;
584
585 let response = s.get("tok", &[]).await?;
586 ensure_eq(response.status().as_u16(), 200, "status after a rename")
587 },
588 ),
589 Case::new(
590 "a_head_answers_the_headers_without_the_body",
591 |s| async move {
592 s.publish("report.pdf", b"0123456789", "tok").await?;
593 let response = s
594 .client
595 .head(s.view.url_for("tok"))
596 .send()
597 .await
598 .map_err(|e| Error::Storage(format!("request: {e}")))?;
599 ensure_eq(response.status().as_u16(), 200, "status")?;
600 ensure_eq(
601 header(&response, "content-length"),
602 Some("10".into()),
603 "content-length",
604 )?;
605 ensure_eq(
606 response.text().await.unwrap_or_default(),
607 String::new(),
608 "a HEAD carried a body",
609 )
610 },
611 ),
612 Case::new("nothing_but_a_token_names_a_file", |s| async move {
613 s.publish("report.pdf", b"body", "tok").await?;
616 for path in ["/", "/f/", "/f/../report.pdf", "/report.pdf", "/health"] {
621 let response = s
622 .client
623 .get(format!("{}{path}", s.view.base_url()))
624 .send()
625 .await
626 .map_err(|e| Error::Storage(format!("request: {e}")))?;
627 ensure(
628 !response.status().is_success(),
629 format!("{path} was served with {}", response.status()),
630 )?;
631 }
632 Ok(())
633 }),
634 ]
635}