gfeh-http 0.1.1

The plain-HTTP view of gfeh: per-file public exposure with ranges and conditional requests
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//! The suite this view must satisfy over every store it can be given.
//!
//! # Why this is a library module rather than a test target
//!
//! A view is written against [`ObjectStore`] and cannot tell which store it has. That
//! substitutability is the design, and it is the thing most likely to quietly stop being
//! true -- a header derived from a field one backend populates and another leaves empty
//! looks correct in every test written against the first.
//!
//! So the behaviour is asserted once, here, and every backend the crate can be driven
//! with runs the identical cases: the in-memory reference, a real filesystem partition,
//! and the composed namespace a daemon actually serves from. A backend that answers
//! differently fails the same case the reference passes.
//!
//! It is a library module for the same reason `gfeh-store`'s is: a test target can only
//! be run by this crate, and the point is that *other* crates -- a daemon, an
//! integration suite, a future backend -- can run the same cases against a store this
//! crate has never heard of.
//!
//! # It speaks HTTP
//!
//! Every case here issues a real request over a real socket against a real listener.
//! Calling the handlers directly would test the same code with the parts that have
//! actually been wrong -- header formats, status codes, the exact bytes of a
//! `Content-Range` -- removed from the loop.
//!
//! # Running it
//!
//! Implement [`Backend`] and hand it to [`run`]. Each case gets its own store, its own
//! listener, and its own exposure table.
//!
//! ```text
//! struct Mem;
//!
//! #[async_trait::async_trait]
//! impl Backend for Mem {
//!     fn name(&self) -> String { "MemStore".into() }
//!     async fn fresh(&self) -> Result<Target> {
//!         let store = gfeh_store::MemStore::new();
//!         Ok(Target { root: store.root(), partition: store.partition(), store: Arc::new(store) })
//!     }
//! }
//!
//! let report = conformance::run(&Mem).await;
//! assert!(report.is_conformant(), "{}", report.summary());
//! ```
//!
//! There is deliberately no built-in backend here. This crate depends on `gfeh-core` and
//! nothing else -- naming `MemStore` would mean depending on `gfeh-store`, which is the
//! one thing a protocol crate may not do, and a stub store written here to avoid that
//! would be a second in-memory implementation for the suite to quietly agree with.

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};

// ---------------------------------------------------------------------------
// The backend under test
// ---------------------------------------------------------------------------

/// A store to run one case against.
pub struct Target {
    /// The store the view is served over.
    pub store: Arc<dyn ObjectStore>,
    /// A reference to the partition root.
    pub root: NodeRef,
    /// The partition the store serves.
    pub partition: PartitionId,
}

/// A factory for stores under test.
#[async_trait]
pub trait Backend: Send + Sync {
    /// The backend's name, used in the report.
    fn name(&self) -> String;

    /// A store containing nothing but an empty root directory.
    ///
    /// # Errors
    ///
    /// Returns whatever the backend's own provisioning fails with. A failure here is
    /// reported against the case that asked for it rather than aborting the run.
    async fn fresh(&self) -> Result<Target>;
}

// ---------------------------------------------------------------------------
// A served view, and the client that drives it
// ---------------------------------------------------------------------------

/// One case's view, its exposure table, and a client pointed at it.
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(),
        })
    }

    /// Write a file into the store and publish it under a token.
    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(())
    }

    /// One GET, with optional headers.
    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}")))
    }
}

// ---------------------------------------------------------------------------
// Cases and the runner
// ---------------------------------------------------------------------------

/// A single behaviour the view must exhibit.
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))),
        }
    }

    /// What this case is called. Stable, so a failure can be looked up.
    #[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()
    }
}

/// What one case did.
#[derive(Debug, Clone)]
pub struct Outcome {
    /// The case's name.
    pub case: &'static str,
    /// The failure, or `None` if it passed.
    pub failure: Option<String>,
}

/// The result of running the suite against one backend.
#[derive(Debug, Clone)]
pub struct Report {
    /// Which backend was tested.
    pub backend: String,
    /// One outcome per case, in suite order.
    pub outcomes: Vec<Outcome>,
}

impl Report {
    /// True when every case passed.
    #[must_use]
    pub fn is_conformant(&self) -> bool {
        self.outcomes.iter().all(|o| o.failure.is_none())
    }

    /// How many cases passed.
    #[must_use]
    pub fn passed(&self) -> usize {
        self.outcomes.iter().filter(|o| o.failure.is_none()).count()
    }

    /// The cases that failed.
    pub fn failures(&self) -> impl Iterator<Item = &Outcome> {
        self.outcomes.iter().filter(|o| o.failure.is_some())
    }

    /// A human-readable summary listing every failure.
    #[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
    }
}

/// Run every case against a backend, collecting all outcomes.
///
/// Never stops early: one broken assumption usually surfaces as several failing cases,
/// and the set of them is what identifies it.
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,
    }
}

/// Fail the case unless `condition` holds.
fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
    if condition {
        Ok(())
    } else {
        Err(Error::Other(message.into()))
    }
}

/// Fail the case unless two values are equal, reporting both.
fn ensure_eq<T: PartialEq + Debug>(actual: T, expected: T, what: &str) -> Result<()> {
    ensure(
        actual == expected,
        format!("{what}: expected {expected:?}, got {actual:?}"),
    )
}

/// One header, as a string.
fn header(response: &reqwest::Response, name: &str) -> Option<String> {
    response
        .headers()
        .get(name)
        .and_then(|v| v.to_str().ok())
        .map(str::to_string)
}

/// Every case in the suite.
#[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 {
                // Telling the holder that the token is real but switched off is information
                // they should not have.
                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 {
            // ISO 8601 here made an S3 object visible in a listing and unreadable by the
            // AWS SDK for Go. An HTTP-date ends in `GMT` and has no `T` separator.
            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()))?;
            // `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`.
            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 {
                // The weaker of the two validators and the one a browser sends without
                // being given anything: it echoes back the `Last-Modified` it was told.
                // A server that ignores it re-sends the whole object on every poll.
                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",
                )?;

                // A date before the object's own is not a match, and the object comes
                // back. This is the direction that matters: getting it wrong hands a
                // client a 304 for a file it has never seen.
                //
                // The date is before the epoch, which looks arbitrary and is not. An
                // HTTP-date has one-second resolution, and the in-memory reference dates
                // everything from a counter starting at zero -- deliberately, so a
                // protocol test can assert on a timestamp at all -- so every object it
                // holds falls in the same second as the epoch. The only date older than
                // every object on every backend is one before it. That the reference
                // cannot distinguish two of its own writes by date is the whole reason
                // an entity tag is the better validator.
                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 {
                // ISO 8601 is not an HTTP-date. A server that guessed at it would be
                // deciding whether to send a file on the strength of a string it did not
                // understand, and the failure mode is a client stuck on a stale copy --
                // where answering with the object costs one re-download.
                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 {
                // Required rather than preferred: a recipient that evaluates an entity
                // tag must ignore the date. The reason is the resolution -- two writes
                // inside one second share a `Last-Modified`, so a client holding the
                // first would be told it is current, where the tag knows better.
                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 {
                // `bytes=-3` is the *last* three bytes. Reading it as "from byte 3" hands a
                // video player the opening credits when it asked for the index at the end.
                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 {
                // A 416 without `Content-Range: bytes */len` leaves a client with no way to
                // learn what it should have asked for.
                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 {
            // A name is user-controlled, so an injection here would be one in every
            // response the view ever sends.
            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 {
                // The token resolves an identity, not a path. A rename that broke a URL
                // already in circulation is the failure the whole design avoids.
                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 {
            // No directory listing, no path surface, and no way to name an object except
            // by a token somebody deliberately minted.
            s.publish("report.pdf", b"body", "tok").await?;
            // Traversal *inside* a token is not in this list: an HTTP client resolves
            // `..` before the request leaves it, so `/f/tok/../tok` never reaches the
            // server as anything but `/f/tok`. What is checked here is the surface --
            // that no path other than `/f/{token}` is served at all.
            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(())
        }),
    ]
}