mini-static 0.38.5

A secure, async static file server with streaming, traversal protection, and connection limits.
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
//! A cached response must be indistinguishable from an uncached one.
//!
//! The differential test is the whole verification of this feature. Asserting that a cached
//! response "looks right" would prove very little; asserting that it is **identical, field by
//! field, to what the same request gets from disk** proves the cache is a performance change
//! and not a behaviour change.
//!
//! Two rules make it worth trusting:
//!
//! 1. **The whole header set is diffed**, not a hardcoded list of interesting ones. A skip list
//!    is where a header the cache forgot would hide.
//! 2. **The fixture holds the awkward cases**, because "identical" proven only for a plain 200
//!    is proven for the easy half. A directory served via its index, a `304`, a satisfiable and
//!    an unsatisfiable range, a `HEAD`, a hidden file, and a path absent from the cache.

mod common;

use std::fs;

use hyper::{Method, Request, StatusCode};
use mini_static::Server;
use tempfile::TempDir;

/// Everything a request can produce that must match between the two servers.
#[derive(Debug, PartialEq, Eq)]
struct Observed {
    status: StatusCode,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

/// Which entry point a request is made through.
///
/// Both are exercised for every case. `HandleRequest` is the direct API; `Respond` is what
/// production actually uses — `mini-serve` decodes the path, hands over segments, and calls
/// `respond`. They take different code paths into the cache lookup, and a mutation removing the
/// hidden-file refusal from the `Respond` branch went **undetected** while only
/// `HandleRequest` was covered. The production path was the untested one.
#[derive(Debug, Clone, Copy)]
enum Entry {
    HandleRequest,
    Respond,
}

/// Split then percent-decode, exactly as `mini-serve`'s router does before handing segments
/// over — so `%2F` stays inside one segment rather than becoming a separator.
fn router_segments(path: &str) -> Vec<String> {
    path.trim_start_matches('/')
        .split('/')
        .filter(|segment| !segment.is_empty())
        .map(|segment| {
            percent_encoding::percent_decode_str(segment)
                .decode_utf8_lossy()
                .into_owned()
        })
        .collect()
}

/// `Date` is the only header allowed to differ, and it is excluded by being absent: these
/// responses come from the handler, which does not set it — the connection layer does. So
/// nothing is skipped here at all, which is the point.
async fn observe(
    server: &Server,
    entry: Entry,
    method: &Method,
    path: &str,
    extra: &[(&str, &str)],
) -> Observed {
    let response = match entry {
        Entry::HandleRequest => common::request_with_headers(server, method, path, extra).await,
        Entry::Respond => {
            let mut builder = Request::builder().method(method.clone()).uri(path);
            for (name, value) in extra {
                builder = builder.header(*name, *value);
            }
            let request = builder.body(()).unwrap();
            server.respond(&request, &router_segments(path)).await
        }
    };
    let status = response.status();
    let mut headers: Vec<(String, String)> = response
        .headers()
        .iter()
        .map(|(name, value)| {
            (
                name.as_str().to_string(),
                String::from_utf8_lossy(value.as_bytes()).into_owned(),
            )
        })
        .collect();
    headers.sort();
    let body = common::body_bytes(response).await.to_vec();
    Observed { status, headers, body }
}

/// One request shape to compare. Named rather than a four-tuple: `(&str, Method, &str,
/// Vec<(&str, &str)>)` is unreadable at the call site and clippy says so.
struct Case {
    label: &'static str,
    method: Method,
    path: &'static str,
    headers: Vec<(&'static str, &'static str)>,
}

fn case(label: &'static str, method: Method, path: &'static str) -> Case {
    Case { label, method, path, headers: Vec::new() }
}

fn case_with(
    label: &'static str,
    method: Method,
    path: &'static str,
    headers: Vec<(&'static str, &'static str)>,
) -> Case {
    Case { label, method, path, headers }
}

/// A root exercising every shape the differential test needs.
fn fixture() -> TempDir {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html>home</html>").unwrap();
    fs::write(root.path().join("app.css"), b"body{color:red}").unwrap();
    fs::write(root.path().join(".env"), b"SECRET=1").unwrap();
    fs::create_dir(root.path().join("docs")).unwrap();
    fs::write(root.path().join("docs/index.html"), b"<html>docs</html>").unwrap();
    // Above `INLINE_BODY_BYTES`, so the uncached path streams it rather than buffering.
    fs::write(root.path().join("big.bin"), vec![b'x'; 128 * 1024]).unwrap();
    root
}

fn pair(root: &TempDir) -> (Server, Server) {
    let uncached = Server::new(root.path()).unwrap();
    let cached = Server::new(root.path())
        .unwrap()
        .with_content_cache(8 << 20)
        .expect("no live-reload configured");
    (uncached, cached)
}

/// Every request shape, both servers, byte for byte.
#[tokio::test]
async fn a_cached_response_is_identical_to_an_uncached_one() {
    let root = fixture();
    let (uncached, cached) = pair(&root);

    let cases = vec![
        case("a small file", Method::GET, "/app.css"),
        case("the root index", Method::GET, "/"),
        case("a directory with a trailing slash", Method::GET, "/docs/"),
        case("a directory without one, which redirects", Method::GET, "/docs"),
        case("an explicit index", Method::GET, "/index.html"),
        case("a file above the inline threshold", Method::GET, "/big.bin"),
        case("a HEAD", Method::HEAD, "/app.css"),
        case("a HEAD of a large file", Method::HEAD, "/big.bin"),
        case("a hidden file", Method::GET, "/.env"),
        case("a path that does not exist", Method::GET, "/missing.css"),
        case("a traversal attempt", Method::GET, "/../etc/passwd"),
        case("an encoded separator", Method::GET, "/docs%2Findex.html"),
        case("a method the engine does not serve", Method::DELETE, "/app.css"),
        case_with("a satisfiable range", Method::GET, "/big.bin", vec![("range", "bytes=100-199")]),
        case_with(
            "an unsatisfiable range",
            Method::GET,
            "/app.css",
            vec![("range", "bytes=9999-99999")],
        ),
        case_with("a suffix range", Method::GET, "/big.bin", vec![("range", "bytes=-50")]),
        case_with(
            "a multi-range, which is ignored",
            Method::GET,
            "/big.bin",
            vec![("range", "bytes=0-9,20-29")],
        ),
    ];

    for Case { label, method, path, headers } in cases {
        for entry in [Entry::HandleRequest, Entry::Respond] {
            let from_disk = observe(&uncached, entry, &method, path, &headers).await;
            let from_memory = observe(&cached, entry, &method, path, &headers).await;
            assert_eq!(
                from_disk, from_memory,
                "cached and uncached responses differ for {label} ({method} {path}) via {entry:?}"
            );
        }
    }
}

/// The `304` path, which needs the ETag from a first response — so it cannot be a row in the
/// table above.
#[tokio::test]
async fn a_conditional_request_matches_between_cached_and_uncached() {
    let root = fixture();
    let (uncached, cached) = pair(&root);

    let etag = {
        let response = common::get(&uncached, "/app.css").await;
        response
            .headers()
            .get("etag")
            .expect("a 200 carries an ETag")
            .to_str()
            .unwrap()
            .to_string()
    };

    let conditional = [("if-none-match", etag.as_str())];
    let from_disk =
        observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &conditional).await;
    let from_memory = observe(&cached, Entry::Respond, &Method::GET, "/app.css", &conditional).await;

    assert_eq!(from_disk.status, StatusCode::NOT_MODIFIED, "expected a 304 from disk");
    assert_eq!(
        from_disk, from_memory,
        "a cached 304 must match an uncached one, ETag included — the ETag is derived from the \
         stored metadata precisely so it cannot drift"
    );
}

/// The cache is actually being used. Without this the differential test above would pass
/// against a cache that was never consulted, which is the failure mode that matters most.
#[tokio::test]
async fn the_cache_is_the_thing_answering() {
    let root = fixture();
    let cached = Server::new(root.path())
        .unwrap()
        .with_content_cache(8 << 20)
        .expect("no live-reload configured");

    // Remove the file from disk after construction. Only a server answering from memory can
    // still serve it.
    fs::remove_file(root.path().join("app.css")).unwrap();

    let response = common::get(&cached, "/app.css").await;
    assert_eq!(
        response.status(),
        StatusCode::OK,
        "the cache did not answer — the request reached a disk that no longer has the file"
    );
    assert_eq!(&common::body_bytes(response).await[..], b"body{color:red}");
}

/// `/` and `/docs/` resolve to an `index.html`, so the cache — which is keyed on files — has to
/// retry with the index appended or it misses the most common request any site receives.
///
/// Only observable with the files gone from disk: with them present, a missed retry simply
/// falls through and serves the same bytes, so the differential test cannot see it. That is
/// what a mutation removing the retry showed.
#[tokio::test]
async fn the_directory_index_is_answered_from_the_cache() {
    let root = fixture();
    let cached = Server::new(root.path())
        .unwrap()
        .with_content_cache(8 << 20)
        .expect("no live-reload configured");

    fs::remove_file(root.path().join("index.html")).unwrap();
    fs::remove_file(root.path().join("docs/index.html")).unwrap();

    for (path, expected) in [("/", &b"<html>home</html>"[..]), ("/docs/", &b"<html>docs</html>"[..])] {
        let response = common::get(&cached, path).await;
        assert_eq!(
            response.status(),
            StatusCode::OK,
            "{path} was not answered from the cache once the index was gone from disk"
        );
        assert_eq!(&common::body_bytes(response).await[..], expected, "{path}");
    }
}

/// A file with a precompressed sibling is declined by the cache and served from disk, so its
/// `Content-Encoding` is not silently lost. Commit 6 caches the variants.
#[tokio::test]
async fn a_file_with_a_sidecar_is_served_from_disk_with_its_encoding() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("app.css"), b"body{}").unwrap();
    fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();

    let cached = Server::new(root.path())
        .unwrap()
        .with_content_cache(8 << 20)
        .expect("no live-reload configured");

    let response =
        common::request_with_headers(&cached, &Method::GET, "/app.css", &[("accept-encoding", "br")])
            .await;

    assert_eq!(
        response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
        Some("br"),
        "the sidecar must still be served; declining to cache it must not lose the encoding"
    );
    assert_eq!(&common::body_bytes(response).await[..], b"BROTLI");
}

/// A cached root serves precompressed variants from memory, with no filesystem access at all.
///
/// **The root is moved aside after construction**, so any `open()` on the hit path fails and the
/// test fails with it. Renaming rather than `chmod 000`: the owning user can traverse a
/// mode-`000` directory on some systems, and the test would then prove nothing.
#[tokio::test]
async fn a_cached_root_serves_variants_without_touching_the_filesystem() {
    let outer = TempDir::new().unwrap();
    let root = outer.path().join("www");
    fs::create_dir(&root).unwrap();
    fs::write(root.join("app.css"), b"body{color:red}").unwrap();
    fs::write(root.join("app.css.br"), b"BROTLI-BYTES").unwrap();
    fs::write(root.join("app.css.gz"), b"GZIP-BYTES").unwrap();

    let cached = Server::new(&root)
        .unwrap()
        .with_content_cache(8 << 20)
        .expect("no live-reload configured");

    // Everything the hit path could reach is now gone from where it was.
    fs::rename(&root, outer.path().join("moved-away")).unwrap();

    for (accept, expected_encoding, expected_body) in [
        ("br, gzip", Some("br"), &b"BROTLI-BYTES"[..]),
        ("gzip", Some("gzip"), &b"GZIP-BYTES"[..]),
        ("identity", None, &b"body{color:red}"[..]),
    ] {
        let response = common::request_with_headers(
            &cached,
            &Method::GET,
            "/app.css",
            &[("accept-encoding", accept)],
        )
        .await;

        assert_eq!(
            response.status(),
            StatusCode::OK,
            "accept-encoding: {accept} did not serve from memory once the root was moved"
        );
        assert_eq!(
            response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
            expected_encoding,
            "wrong Content-Encoding for accept-encoding: {accept}"
        );
        assert_eq!(&common::body_bytes(response).await[..], expected_body, "for {accept}");
    }
}

/// The client's stated preference is honoured, not the server's listing order — and it is
/// honoured identically whether the variant comes from memory or from disk, because both use
/// the same negotiation.
#[tokio::test]
async fn a_cached_variant_honours_quality_values_like_the_disk_path() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("app.css"), b"plain").unwrap();
    fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();
    fs::write(root.path().join("app.css.gz"), b"GZIP").unwrap();

    let (uncached, cached) = (
        Server::new(root.path()).unwrap(),
        Server::new(root.path())
            .unwrap()
            .with_content_cache(8 << 20)
            .expect("no live-reload configured"),
    );

    // `br` is listed first server-side, but the client prefers gzip.
    for accept in ["br;q=0.5, gzip", "gzip;q=1.0, br;q=0.1", "br, gzip"] {
        let from_disk =
            observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &[("accept-encoding", accept)])
                .await;
        let from_memory =
            observe(&cached, Entry::Respond, &Method::GET, "/app.css", &[("accept-encoding", accept)])
                .await;
        assert_eq!(
            from_disk, from_memory,
            "cached and uncached negotiation differ for accept-encoding: {accept}"
        );
    }
}

/// Budget truncation can hold a file without holding its variant. The variant must still be
/// found on disk, or a cached server would serve an unencoded body where an uncached one serves
/// a compressed one.
#[tokio::test]
async fn a_cached_file_whose_variant_was_not_cached_still_serves_the_variant() {
    let root = TempDir::new().unwrap();
    // Sorted enumeration visits `app.css` before `app.css.br`, so a budget between the two
    // holds the plain file and truncates before the variant.
    fs::write(root.path().join("app.css"), vec![b'x'; 100]).unwrap();
    fs::write(root.path().join("app.css.br"), vec![b'b'; 100]).unwrap();

    let (uncached, cached) = (
        Server::new(root.path()).unwrap(),
        Server::new(root.path())
            .unwrap()
            .with_content_cache(150)
            .expect("no live-reload configured"),
    );

    let accept = [("accept-encoding", "br")];
    let from_disk = observe(&uncached, Entry::Respond, &Method::GET, "/app.css", &accept).await;
    let from_memory = observe(&cached, Entry::Respond, &Method::GET, "/app.css", &accept).await;

    assert_eq!(
        from_disk.headers, from_memory.headers,
        "a partially cached file must still negotiate its encoding the same way"
    );
    assert_eq!(from_disk.body, from_memory.body, "the variant's bytes must be served");
}

/// The client's stated `q` values decide, not the server's listing order.
///
/// Absolute rather than differential: the crate had **no test for this at all**, so removing the
/// quality sort passed the entire suite. A differential test cannot catch a bug both paths share.
#[tokio::test]
async fn the_clients_quality_preference_decides_the_encoding() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("app.css"), b"plain").unwrap();
    fs::write(root.path().join("app.css.br"), b"BROTLI").unwrap();
    fs::write(root.path().join("app.css.gz"), b"GZIP").unwrap();

    for server in [
        Server::new(root.path()).unwrap(),
        Server::new(root.path())
            .unwrap()
            .with_content_cache(8 << 20)
            .expect("no live-reload configured"),
    ] {
        // `br` is listed first server-side; the client says it would rather have gzip.
        let response = common::request_with_headers(
            &server,
            &Method::GET,
            "/app.css",
            &[("accept-encoding", "br;q=0.5, gzip")],
        )
        .await;
        assert_eq!(
            response.headers().get("content-encoding").map(|v| v.to_str().unwrap()),
            Some("gzip"),
            "the server preferred its own order over the client's stated q values"
        );
        assert_eq!(&common::body_bytes(response).await[..], b"GZIP");
    }
}