Skip to main content

gfeh_http/
conformance.rs

1//! The suite this view must satisfy over every store it can be given.
2//!
3//! # Why this is a library module rather than a test target
4//!
5//! A view is written against [`ObjectStore`] and cannot tell which store it has. That
6//! substitutability is the design, and it is the thing most likely to quietly stop being
7//! true -- a header derived from a field one backend populates and another leaves empty
8//! looks correct in every test written against the first.
9//!
10//! So the behaviour is asserted once, here, and every backend the crate can be driven
11//! with runs the identical cases: the in-memory reference, a real filesystem partition,
12//! and the composed namespace a daemon actually serves from. A backend that answers
13//! differently fails the same case the reference passes.
14//!
15//! It is a library module for the same reason `gfeh-store`'s is: a test target can only
16//! be run by this crate, and the point is that *other* crates -- a daemon, an
17//! integration suite, a future backend -- can run the same cases against a store this
18//! crate has never heard of.
19//!
20//! # It speaks HTTP
21//!
22//! Every case here issues a real request over a real socket against a real listener.
23//! Calling the handlers directly would test the same code with the parts that have
24//! actually been wrong -- header formats, status codes, the exact bytes of a
25//! `Content-Range` -- removed from the loop.
26//!
27//! # Running it
28//!
29//! Implement [`Backend`] and hand it to [`run`]. Each case gets its own store, its own
30//! listener, and its own exposure table.
31//!
32//! ```text
33//! struct Mem;
34//!
35//! #[async_trait::async_trait]
36//! impl Backend for Mem {
37//!     fn name(&self) -> String { "MemStore".into() }
38//!     async fn fresh(&self) -> Result<Target> {
39//!         let store = gfeh_store::MemStore::new();
40//!         Ok(Target { root: store.root(), partition: store.partition(), store: Arc::new(store) })
41//!     }
42//! }
43//!
44//! let report = conformance::run(&Mem).await;
45//! assert!(report.is_conformant(), "{}", report.summary());
46//! ```
47//!
48//! There is deliberately no built-in backend here. This crate depends on `gfeh-core` and
49//! nothing else -- naming `MemStore` would mean depending on `gfeh-store`, which is the
50//! one thing a protocol crate may not do, and a stub store written here to avoid that
51//! would be a second in-memory implementation for the suite to quietly agree with.
52
53use 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
62// ---------------------------------------------------------------------------
63// The backend under test
64// ---------------------------------------------------------------------------
65
66/// A store to run one case against.
67pub struct Target {
68    /// The store the view is served over.
69    pub store: Arc<dyn ObjectStore>,
70    /// A reference to the partition root.
71    pub root: NodeRef,
72    /// The partition the store serves.
73    pub partition: PartitionId,
74}
75
76/// A factory for stores under test.
77#[async_trait]
78pub trait Backend: Send + Sync {
79    /// The backend's name, used in the report.
80    fn name(&self) -> String;
81
82    /// A store containing nothing but an empty root directory.
83    ///
84    /// # Errors
85    ///
86    /// Returns whatever the backend's own provisioning fails with. A failure here is
87    /// reported against the case that asked for it rather than aborting the run.
88    async fn fresh(&self) -> Result<Target>;
89}
90
91// ---------------------------------------------------------------------------
92// A served view, and the client that drives it
93// ---------------------------------------------------------------------------
94
95/// One case's view, its exposure table, and a client pointed at it.
96pub 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    /// Write a file into the store and publish it under a token.
122    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    /// One GET, with optional headers.
154    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
166// ---------------------------------------------------------------------------
167// Cases and the runner
168// ---------------------------------------------------------------------------
169
170/// A single behaviour the view must exhibit.
171pub 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    /// What this case is called. Stable, so a failure can be looked up.
189    #[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/// What one case did.
202#[derive(Debug, Clone)]
203pub struct Outcome {
204    /// The case's name.
205    pub case: &'static str,
206    /// The failure, or `None` if it passed.
207    pub failure: Option<String>,
208}
209
210/// The result of running the suite against one backend.
211#[derive(Debug, Clone)]
212pub struct Report {
213    /// Which backend was tested.
214    pub backend: String,
215    /// One outcome per case, in suite order.
216    pub outcomes: Vec<Outcome>,
217}
218
219impl Report {
220    /// True when every case passed.
221    #[must_use]
222    pub fn is_conformant(&self) -> bool {
223        self.outcomes.iter().all(|o| o.failure.is_none())
224    }
225
226    /// How many cases passed.
227    #[must_use]
228    pub fn passed(&self) -> usize {
229        self.outcomes.iter().filter(|o| o.failure.is_none()).count()
230    }
231
232    /// The cases that failed.
233    pub fn failures(&self) -> impl Iterator<Item = &Outcome> {
234        self.outcomes.iter().filter(|o| o.failure.is_some())
235    }
236
237    /// A human-readable summary listing every failure.
238    #[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
254/// Run every case against a backend, collecting all outcomes.
255///
256/// Never stops early: one broken assumption usually surfaces as several failing cases,
257/// and the set of them is what identifies it.
258pub 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
279/// Fail the case unless `condition` holds.
280fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
281    if condition {
282        Ok(())
283    } else {
284        Err(Error::Other(message.into()))
285    }
286}
287
288/// Fail the case unless two values are equal, reporting both.
289fn 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
296/// One header, as a string.
297fn 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/// Every case in the suite.
306#[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                // Telling the holder that the token is real but switched off is information
328                // they should not have.
329                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            // ISO 8601 here made an S3 object visible in a listing and unreadable by the
348            // AWS SDK for Go. An HTTP-date ends in `GMT` and has no `T` separator.
349            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            // `Thu, 01 Jan 1970 00:00:00 GMT`: a day name, a comma, and GMT at the end.
354            // ISO 8601 is `1970-01-01T00:00:00Z`, so the hyphen is what tells them apart
355            // -- checking for a `T` would reject `Thu` and `Tue`.
356            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                // The weaker of the two validators and the one a browser sends without
381                // being given anything: it echoes back the `Last-Modified` it was told.
382                // A server that ignores it re-sends the whole object on every poll.
383                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                // A date before the object's own is not a match, and the object comes
397                // back. This is the direction that matters: getting it wrong hands a
398                // client a 304 for a file it has never seen.
399                //
400                // The date is before the epoch, which looks arbitrary and is not. An
401                // HTTP-date has one-second resolution, and the in-memory reference dates
402                // everything from a counter starting at zero -- deliberately, so a
403                // protocol test can assert on a timestamp at all -- so every object it
404                // holds falls in the same second as the epoch. The only date older than
405                // every object on every backend is one before it. That the reference
406                // cannot distinguish two of its own writes by date is the whole reason
407                // an entity tag is the better validator.
408                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                // ISO 8601 is not an HTTP-date. A server that guessed at it would be
421                // deciding whether to send a file on the strength of a string it did not
422                // understand, and the failure mode is a client stuck on a stale copy --
423                // where answering with the object costs one re-download.
424                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                // Required rather than preferred: a recipient that evaluates an entity
440                // tag must ignore the date. The reason is the resolution -- two writes
441                // inside one second share a `Last-Modified`, so a client holding the
442                // first would be told it is current, where the tag knows better.
443                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                // `bytes=-3` is the *last* three bytes. Reading it as "from byte 3" hands a
483                // video player the opening credits when it asked for the index at the end.
484                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                // A 416 without `Content-Range: bytes */len` leaves a client with no way to
508                // learn what it should have asked for.
509                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            // A name is user-controlled, so an injection here would be one in every
544            // response the view ever sends.
545            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                // The token resolves an identity, not a path. A rename that broke a URL
568                // already in circulation is the failure the whole design avoids.
569                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            // No directory listing, no path surface, and no way to name an object except
614            // by a token somebody deliberately minted.
615            s.publish("report.pdf", b"body", "tok").await?;
616            // Traversal *inside* a token is not in this list: an HTTP client resolves
617            // `..` before the request leaves it, so `/f/tok/../tok` never reaches the
618            // server as anything but `/f/tok`. What is checked here is the surface --
619            // that no path other than `/f/{token}` is served at all.
620            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}