mini-static 0.38.7

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
//! What the cache may read, over a real filesystem.
//!
//! Real entries rather than constructed `FileType` values: `FileType` cannot be built by
//! hand, and a test that could would be testing a stand-in for the thing that matters.
//! Every case here is a directory entry the population walk will actually meet.

use super::is_cacheable;

use std::fs;
use std::path::Path;

/// The `FileType` the population walk sees for `name`, from `read_dir` — which is where the
/// walk gets it, and which does not traverse symlinks.
fn entry_type(dir: &Path, name: &str) -> fs::FileType {
    fs::read_dir(dir)
        .expect("read the fixture directory")
        .filter_map(Result::ok)
        .find(|entry| entry.file_name() == name)
        .unwrap_or_else(|| panic!("no entry named {name} in the fixture"))
        .file_type()
        .expect("read the entry's file type")
}

#[test]
fn an_ordinary_file_is_cacheable() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("styles.css"), b"body{}").unwrap();

    assert!(is_cacheable(&entry_type(root.path(), "styles.css")));
}

#[test]
fn an_empty_file_is_cacheable() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("empty.txt"), b"").unwrap();

    assert!(
        is_cacheable(&entry_type(root.path(), "empty.txt")),
        "a zero-length file is still a file; refusing it would be an accident, not a policy"
    );
}

#[test]
fn a_directory_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    fs::create_dir(root.path().join("assets")).unwrap();

    assert!(
        !is_cacheable(&entry_type(root.path(), "assets")),
        "a directory is descended, never cached"
    );
}

/// The rule that makes containment unnecessary. If this ever returns true, the walk can
/// leave the root and the cache needs its own containment check — which is the second
/// resolution path this design exists to avoid.
#[test]
fn a_symlink_to_a_file_inside_the_root_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("real.css"), b"body{}").unwrap();
    std::os::unix::fs::symlink(root.path().join("real.css"), root.path().join("link.css")).unwrap();

    assert!(
        !is_cacheable(&entry_type(root.path(), "link.css")),
        "a symlink is refused even pointing inside the root, so the walk never follows one"
    );
}

#[test]
fn a_symlink_to_a_file_outside_the_root_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    fs::write(outside.path().join("secret"), b"not yours").unwrap();
    std::os::unix::fs::symlink(outside.path().join("secret"), root.path().join("escape")).unwrap();

    assert!(
        !is_cacheable(&entry_type(root.path(), "escape")),
        "a symlink out of the root must never be cached"
    );
}

#[test]
fn a_symlink_to_a_directory_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    std::os::unix::fs::symlink(outside.path(), root.path().join("elsewhere")).unwrap();

    assert!(
        !is_cacheable(&entry_type(root.path(), "elsewhere")),
        "a symlinked directory is neither cached nor descended"
    );
}

/// `File::open` on a FIFO blocks until a writer appears. On the request path that costs one
/// request; during an eager walk it would hang startup, so this is a bound (A2), not tidiness.
#[test]
fn a_fifo_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    let fifo = root.path().join("pipe");
    let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
    // SAFETY: `c_path` is a valid NUL-terminated path that lives across the call.
    let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
    assert_eq!(rc, 0, "could not create the fifo fixture");

    assert!(
        !is_cacheable(&entry_type(root.path(), "pipe")),
        "opening a fifo during the walk would hang construction"
    );
}

#[test]
fn a_unix_socket_is_not_cacheable() {
    let root = tempfile::tempdir().unwrap();
    let socket = root.path().join("sock");
    let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();

    assert!(
        !is_cacheable(&entry_type(root.path(), "sock")),
        "a socket is not a file to read"
    );
}

/// The property `is_cacheable` alone cannot prove: the walk does not *descend* a symlinked
/// directory, so nothing beneath one is ever enumerated.
///
/// Without this, a root containing a link to `/etc` would have its contents read into memory
/// and served under in-root paths. The predicate refusing the link is necessary but not
/// sufficient — the walk has to refuse to follow it too.
#[test]
fn the_walk_does_not_descend_a_symlinked_directory() {
    let root = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    fs::write(outside.path().join("secret"), b"not yours").unwrap();
    std::os::unix::fs::symlink(outside.path(), root.path().join("elsewhere")).unwrap();
    fs::write(root.path().join("real.css"), b"body{}").unwrap();

    let found = super::cacheable_entries(root.path());

    assert_eq!(found.len(), 1, "expected only the real file, got: {found:?}");
    assert!(found[0].ends_with("real.css"));
    assert!(
        !found.iter().any(|p| p.ends_with("secret")),
        "the walk followed a symlinked directory and enumerated a file outside the root"
    );
}

#[test]
fn the_walk_descends_real_directories() {
    let root = tempfile::tempdir().unwrap();
    fs::create_dir_all(root.path().join("a/b")).unwrap();
    fs::write(root.path().join("top.css"), b"1").unwrap();
    fs::write(root.path().join("a/mid.css"), b"2").unwrap();
    fs::write(root.path().join("a/b/deep.css"), b"3").unwrap();

    let mut names: Vec<String> = super::cacheable_entries(root.path())
        .iter()
        .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
        .collect();
    names.sort();

    assert_eq!(names, vec!["deep.css", "mid.css", "top.css"]);
}

/// Every non-file entry is refused by the walk as well as by the predicate — a directory is
/// descended, and nothing else is enumerated.
#[test]
fn the_walk_enumerates_only_regular_files() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("keep.css"), b"1").unwrap();
    fs::create_dir(root.path().join("dir")).unwrap();
    std::os::unix::fs::symlink(root.path().join("keep.css"), root.path().join("link")).unwrap();
    let socket = root.path().join("sock");
    let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();

    let found = super::cacheable_entries(root.path());

    assert_eq!(found.len(), 1, "expected only keep.css, got: {found:?}");
    assert!(found[0].ends_with("keep.css"));
}

/// An unreadable subdirectory costs that subtree its caching, not the server its startup.
#[test]
fn an_unreadable_directory_is_skipped_rather_than_fatal() {
    use std::os::unix::fs::PermissionsExt;

    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("readable.css"), b"1").unwrap();
    let blocked = root.path().join("blocked");
    fs::create_dir(&blocked).unwrap();
    fs::write(blocked.join("hidden.css"), b"2").unwrap();
    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();

    let found = super::cacheable_entries(root.path());

    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).unwrap();
    assert!(
        found.iter().any(|p| p.ends_with("readable.css")),
        "the readable file should still be enumerated: {found:?}"
    );
}

/// The enumeration is bounded (A2), tested against an injected ceiling.
///
/// The first version of this test made sixty-four files and asserted `len() <= 65_536`, which
/// holds whether or not the ceiling is enforced. Mutation testing caught it: deleting the
/// check left the test green. It now asserts the ceiling actually truncates.
#[test]
fn the_walk_stops_at_its_ceiling() {
    let root = tempfile::tempdir().unwrap();
    for n in 0..10 {
        fs::write(root.path().join(format!("f{n}.css")), b"x").unwrap();
    }

    let found = super::cacheable_entries_bounded(root.path(), 3);

    assert_eq!(found.len(), 3, "the ceiling did not truncate the walk: {found:?}");
}

// No test that the entry point passes `MAX_CACHE_ENTRIES` rather than an unbounded value:
// distinguishing the two needs 65,537 fixture files, and a smaller fixture cannot tell them
// apart. A version of that test existed and mutation testing showed it green with the
// constant replaced by `usize::MAX` — it looked like verification and was not. The ceiling's
// *enforcement* is proven by `the_walk_stops_at_its_ceiling` above; that the entry point
// hands over the named constant is a one-line call reviewed at the site.

/// Enumeration is sorted, so truncating at the ceiling takes the same subset every time.
///
/// Without this, two servers on identical roots cache different files once a budget is
/// exhausted, and the hit rate depends on the order the files happened to be created.
#[test]
fn the_walk_returns_a_deterministic_order() {
    let root = tempfile::tempdir().unwrap();
    // Created in an order that is not sorted order, so a `read_dir` passthrough would show it.
    for name in ["zebra.css", "alpha.css", "middle.css"] {
        fs::write(root.path().join(name), b"x").unwrap();
    }

    let found = super::cacheable_entries(root.path());
    let mut sorted = found.clone();
    sorted.sort();

    assert_eq!(found, sorted, "enumeration was not in sorted order: {found:?}");

    // Truncated output is sorted too. It is *not* the sorted first two — it is the first two
    // encountered, then sorted, because collecting every path before truncating is the
    // unbounded work the ceiling prevents. This asserts the property that holds.
    let truncated = super::cacheable_entries_bounded(root.path(), 2);
    let mut expected = truncated.clone();
    expected.sort();
    assert_eq!(truncated, expected, "truncated output was not sorted: {truncated:?}");
    assert_eq!(truncated.len(), 2);
}

// ---------------------------------------------------------------------------
// Population. Nothing here serves anything; these assert what is held and what
// is refused, which is the half a throughput benchmark cannot check.
// ---------------------------------------------------------------------------

/// Keys are relative to the root, and only cacheable files are held.
#[test]
fn population_holds_the_cacheable_files_keyed_relative_to_the_root() {
    let root = tempfile::tempdir().unwrap();
    fs::create_dir(root.path().join("assets")).unwrap();
    fs::write(root.path().join("index.html"), b"<html>").unwrap();
    fs::write(root.path().join("assets/app.css"), b"body{}").unwrap();
    std::os::unix::fs::symlink(root.path().join("index.html"), root.path().join("link.html"))
        .unwrap();

    let cache = super::populate(root.path(), 1 << 20);

    assert_eq!(cache.len(), 2, "expected exactly the two real files");
    assert_eq!(cache.bytes_held(), b"<html>".len() + b"body{}".len());
    assert!(cache.get(Path::new("index.html")).is_some());
    assert!(
        cache.get(Path::new("assets/app.css")).is_some(),
        "a nested file must be keyed by its path relative to the root, not its basename"
    );
    assert!(
        cache.get(Path::new("link.html")).is_none(),
        "a symlink must not be cached"
    );
    assert!(!cache.truncated());
}

/// The bytes held are the file's bytes, not a truncation or a re-read.
#[test]
fn a_cached_entry_holds_the_files_contents_and_metadata() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("app.css"), b"body{color:red}").unwrap();

    let cache = super::populate(root.path(), 1 << 20);
    let entry = cache.get(Path::new("app.css")).expect("the file should be cached");

    assert_eq!(&entry.bytes[..], b"body{color:red}");
    assert_eq!(
        entry.metadata.len(),
        b"body{color:red}".len() as u64,
        "the stored metadata must describe the file, since the ETag is derived from it"
    );
}

/// A key is byte-exact, so two filenames that differ only outside UTF-8 stay distinct.
///
/// **Linux only, and not by preference.** APFS and HFS+ reject a filename that is not valid
/// UTF-8 — creating this fixture on macOS fails with `EILSEQ`, "illegal byte sequence" — so the
/// case cannot be constructed on the machine this crate is developed on. It is real on ext4,
/// where any byte but `/` and NUL is a legal filename, and the defect it guards against is
/// serious: a `String` key built with `to_string_lossy` maps both of these names onto
/// `a\u{fffd}.css`, so one file's bytes would be served for a request for the other.
///
/// On macOS the property is a type-level one instead: the key is a `PathBuf`, and no lossy
/// conversion appears anywhere in `populate`. That is reviewable rather than testable here,
/// which is worth saying plainly rather than leaving the gap unmarked.
#[cfg(target_os = "linux")]
#[test]
fn a_non_utf8_filename_is_keyed_without_loss() {
    use std::ffi::OsStr;
    use std::os::unix::ffi::OsStrExt;

    let root = tempfile::tempdir().unwrap();
    // Two names that a lossy conversion would collapse onto the same replacement character.
    let first = OsStr::from_bytes(b"a\xff.css");
    let second = OsStr::from_bytes(b"a\xfe.css");
    fs::write(root.path().join(first), b"first").unwrap();
    fs::write(root.path().join(second), b"second").unwrap();

    let cache = super::populate(root.path(), 1 << 20);

    assert_eq!(cache.len(), 2, "a lossy key would have collapsed these into one");
    assert_eq!(
        &cache.get(Path::new(first)).expect("first").bytes[..],
        b"first",
        "the wrong file's bytes would be served if keys were lossy"
    );
    assert_eq!(&cache.get(Path::new(second)).expect("second").bytes[..], b"second");
}

/// Exceeding the budget truncates, deterministically, and says so.
#[test]
fn the_budget_truncates_a_deterministic_prefix() {
    let root = tempfile::tempdir().unwrap();
    for name in ["c.css", "a.css", "b.css"] {
        fs::write(root.path().join(name), vec![b'x'; 100]).unwrap();
    }

    // Room for two of the three hundred-byte files.
    let cache = super::populate(root.path(), 250);

    assert!(cache.truncated(), "the budget was exceeded and should be reported");
    assert_eq!(cache.len(), 2);
    assert!(
        cache.get(Path::new("a.css")).is_some() && cache.get(Path::new("b.css")).is_some(),
        "sorted enumeration means the prefix is a.css and b.css, on any filesystem"
    );
    assert!(cache.bytes_held() <= 250, "the budget was exceeded: {}", cache.bytes_held());
}

/// A zero budget caches nothing and is not an error.
#[test]
fn a_zero_budget_caches_nothing() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("app.css"), b"body{}").unwrap();

    let cache = super::populate(root.path(), 0);

    assert_eq!(cache.len(), 0);
    assert!(cache.truncated());
}

/// A `.br` sibling is recorded from the enumerated set, without opening it.
#[test]
fn a_precompressed_sibling_is_recorded() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("app.css"), b"body{}").unwrap();
    fs::write(root.path().join("app.css.br"), b"brotli").unwrap();
    fs::write(root.path().join("plain.css"), b"body{}").unwrap();

    let cache = super::populate(root.path(), 1 << 20);

    assert!(
        cache.get(Path::new("app.css")).unwrap().has_precompressed_sibling,
        "app.css has a .br sibling and must be marked"
    );
    assert!(
        !cache.get(Path::new("plain.css")).unwrap().has_precompressed_sibling,
        "plain.css has no sibling and must not be marked"
    );
    assert_eq!(cache.with_siblings(), 1);
}

/// A FIFO under the root must not hang population. Deadlined, because the failure mode is a
/// hang rather than a wrong answer — the shape of bug a plain assertion cannot catch.
#[test]
fn a_fifo_under_the_root_does_not_hang_population() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("app.css"), b"body{}").unwrap();
    let fifo = root.path().join("pipe");
    let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
    // SAFETY: `c_path` is a valid NUL-terminated path living across the call.
    assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);

    let root_path = root.path().to_path_buf();
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let cache = super::populate(&root_path, 1 << 20);
        let _ = tx.send(cache.len());
    });

    let cached = rx
        .recv_timeout(std::time::Duration::from_secs(10))
        .expect("population hung — a fifo was opened");
    assert_eq!(cached, 1, "only app.css should be cached");
}

/// The budget stops at the first file that will not fit — it does not skip it and pack
/// smaller ones in behind.
///
/// Three equal-sized files cannot tell those apart, which is what the first version of the
/// truncation test used, and mutation testing showed `break` and `continue` indistinguishable.
/// Sizes chosen so the two behaviours give different sets: stopping holds only `a`, packing
/// would skip `b` and take `c`.
#[test]
fn the_budget_stops_rather_than_packing() {
    let root = tempfile::tempdir().unwrap();
    fs::write(root.path().join("a.css"), vec![b'x'; 100]).unwrap();
    fs::write(root.path().join("b.css"), vec![b'x'; 200]).unwrap();
    fs::write(root.path().join("c.css"), vec![b'x'; 50]).unwrap();

    let cache = super::populate(root.path(), 250);

    assert_eq!(cache.len(), 1, "expected only a.css, got {} entries", cache.len());
    assert!(cache.get(Path::new("a.css")).is_some());
    assert!(
        cache.get(Path::new("c.css")).is_none(),
        "c.css fits in the remaining budget, but packing it would make the cached set depend \
         on file sizes rather than on sorted order"
    );
    assert!(cache.truncated());
}