Skip to main content

qk_cache/
evict.rs

1//! Keeps the cache under its size limit by evicting the least recently used
2//! entries, as Nx's `maxCacheSize` does.
3//!
4//! An entry's last use is its manifest's modification time, refreshed on every
5//! hit. Blobs are shared between entries, so a blob is deleted only once no
6//! remaining manifest cites it. A blob no manifest cites may belong to an entry
7//! another run is still publishing, so it is kept until it is an hour old.
8//! Blobs a fetch relies on are [pinned](pin) until it publishes its record,
9//! and kept whatever their age or how many manifests cite them.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs::{self, File};
13use std::io::{BufRead, Write};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime};
16
17use anyhow::{Context, Result, bail};
18use fs2::FileExt;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// How long an unreferenced blob or scratch file may be part of a publish.
23const IN_FLIGHT: Duration = Duration::from_secs(60 * 60);
24
25#[derive(Debug, Default, PartialEq, Eq)]
26pub struct Pruned {
27    /// Bytes the cache holds afterwards.
28    pub size: u64,
29    pub entries: usize,
30    pub blobs: usize,
31    pub freed: u64,
32}
33
34/// Nx's `parseMaxCacheSize`: a number of bytes with an optional `KB`, `MB` or
35/// `GB` unit, in powers of 1024.
36pub fn parse_size(text: &str) -> Result<u64> {
37    let text = text.trim();
38    let (number, unit) = match text.find(|c: char| !(c.is_ascii_digit() || c == '.')) {
39        Some(at) => (&text[..at], text[at..].trim_start()),
40        None => (text, ""),
41    };
42    let factor: u64 = match unit {
43        "" | "B" => 1,
44        "KB" => 1 << 10,
45        "MB" => 1 << 20,
46        "GB" => 1 << 30,
47        _ => bail!("invalid cache size {text:?}: use a number with an optional KB, MB or GB"),
48    };
49    if number.matches('.').count() > 1 || number.is_empty() {
50        bail!("invalid cache size {text:?}");
51    }
52    let number: f64 = number
53        .parse()
54        .with_context(|| format!("invalid cache size {text:?}"))?;
55    Ok((number * factor as f64) as u64)
56}
57
58/// The size limit: `NX_MAX_CACHE_SIZE`, then nx.json `maxCacheSize`, then, as
59/// in Nx, a tenth of the disk holding the cache. `None` when the limit is 0,
60/// which means unlimited.
61pub fn max_size(configured: Option<&Value>, cache: &Path) -> Result<Option<u64>> {
62    let configured = match std::env::var("NX_MAX_CACHE_SIZE") {
63        Ok(value) => Some(value),
64        Err(_) => configured.map(|value| match value {
65            Value::String(text) => text.clone(),
66            other => other.to_string(),
67        }),
68    };
69    let limit = match configured {
70        Some(text) => parse_size(&text)?,
71        None => {
72            let existing = cache
73                .ancestors()
74                .find(|path| path.exists())
75                .context("cache path has no existing ancestor")?;
76            fs2::total_space(existing)? / 10
77        }
78    };
79    Ok((limit > 0).then_some(limit))
80}
81
82/// Where pins live: under `locks/`, whose dot-named items are not lock files.
83fn pins(root: &Path) -> PathBuf {
84    root.join("locks").join(".pins")
85}
86
87/// Held shared while a pin is made, and exclusively by a prune while it
88/// deletes, so a pin made while a prune runs waits until it is done.
89fn pin_gate(root: &Path) -> Result<File> {
90    let directory = pins(root);
91    fs::create_dir_all(&directory)?;
92    Ok(File::options()
93        .create(true)
94        .truncate(false)
95        .write(true)
96        .open(directory.join(".gate"))?)
97}
98
99/// Blobs kept from eviction while this is alive. Each pin is two files: one
100/// its holder keeps locked, so a prune can tell a live pin from an abandoned
101/// one, and one naming the blobs, which a prune can read even where a lock
102/// keeps other processes from reading the file it covers.
103#[derive(Debug)]
104pub(crate) struct Pin {
105    lock: Option<File>,
106    path: PathBuf,
107}
108
109/// Keeps `blobs` from being pruned until the returned [`Pin`] is dropped. A
110/// prune already deleting finishes first, so a caller that checks which blobs
111/// exist after pinning sees every deletion.
112pub(crate) fn pin(root: &Path, blobs: &BTreeSet<String>) -> Result<Pin> {
113    let gate = pin_gate(root)?;
114    gate.lock_shared()?;
115    let directory = pins(root);
116    let name = format!(
117        "{}-{}",
118        std::process::id(),
119        &blake3::hash(
120            format!("{:?}{:?}", std::thread::current().id(), SystemTime::now()).as_bytes()
121        )
122        .to_hex()[..16]
123    );
124    let path = directory.join(name);
125    let lock = File::options()
126        .create_new(true)
127        .write(true)
128        .open(path.with_extension("lock"))?;
129    lock.lock_exclusive()?;
130    let pin = Pin {
131        lock: Some(lock),
132        path,
133    };
134    let mut listing = String::new();
135    for blob in blobs {
136        listing.push_str(blob);
137        listing.push('\n');
138    }
139    let mut file = File::create(pin.path.with_extension("blobs"))?;
140    file.write_all(listing.as_bytes())?;
141    drop(gate);
142    Ok(pin)
143}
144
145impl Drop for Pin {
146    fn drop(&mut self) {
147        let _ = fs::remove_file(self.path.with_extension("blobs"));
148        // Unlock last: a prune that finds the lock free removes what is left.
149        drop(self.lock.take());
150        let _ = fs::remove_file(self.path.with_extension("lock"));
151    }
152}
153
154/// The blobs live pins hold, removing pins whose holder is gone.
155fn pinned(root: &Path) -> BTreeSet<String> {
156    let mut held = BTreeSet::new();
157    for item in fs::read_dir(pins(root)).into_iter().flatten().flatten() {
158        let path = item.path();
159        if path.extension().is_none_or(|extension| extension != "lock") {
160            continue;
161        }
162        let Ok(lock) = File::open(&path) else {
163            continue;
164        };
165        if lock.try_lock_exclusive().is_ok() {
166            let _ = fs::remove_file(path.with_extension("blobs"));
167            let _ = fs::remove_file(&path);
168            continue;
169        }
170        if let Ok(listing) = File::open(path.with_extension("blobs")) {
171            held.extend(
172                std::io::BufReader::new(listing)
173                    .lines()
174                    .map_while(Result::ok),
175            );
176        }
177    }
178    held
179}
180
181/// Refreshes an entry's last use.
182pub(crate) fn touch(manifest: &Path) {
183    if let Ok(file) = File::options().write(true).open(manifest) {
184        let _ = file.set_modified(SystemTime::now());
185    }
186}
187
188struct Entry {
189    path: std::path::PathBuf,
190    used: SystemTime,
191    size: u64,
192    blobs: BTreeSet<String>,
193}
194
195/// Evicts least recently used entries until the cache holds at most `limit`
196/// bytes, and removes what no entry needs. Another prune in progress makes
197/// this one return without doing anything.
198///
199/// Explicit pruning always remeasures the cache, including files changed in
200/// place by external tools. Normal runs use [`prune_automatic`].
201pub fn prune(root: &Path, limit: u64) -> Result<Pruned> {
202    prune_inner(root, limit, false)
203}
204
205/// Enforces the limit after a run, reusing size accounting while the cache's
206/// directories are unchanged. Normal publication replaces records atomically
207/// and blobs are immutable, so directory changes invalidate the memo. A full
208/// reconciliation still runs at least once an hour.
209pub fn prune_automatic(root: &Path, limit: u64) -> Result<Pruned> {
210    prune_inner(root, limit, true)
211}
212
213fn prune_inner(root: &Path, limit: u64, automatic: bool) -> Result<Pruned> {
214    if !root.join("entries").is_dir() {
215        return Ok(Pruned::default());
216    }
217    fs::create_dir_all(root.join("locks"))?;
218    let marker = root.join("locks").join(".prune");
219    let recent = fs::metadata(&marker)
220        .ok()
221        .and_then(|metadata| metadata.modified().ok())
222        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
223        .is_some_and(|age| age < IN_FLIGHT);
224    if automatic && recent {
225        let size = cached_size(root)?;
226        if size <= limit {
227            return Ok(Pruned {
228                size,
229                ..Pruned::default()
230            });
231        }
232    }
233    let guard = File::options()
234        .create(true)
235        .truncate(false)
236        .write(true)
237        .open(&marker)?;
238    if guard.try_lock_exclusive().is_err() {
239        return Ok(Pruned::default());
240    }
241    // Never associate a computed post-eviction size with stamps taken only
242    // after eviction: a concurrent publisher could have added uncounted bytes.
243    let _ = fs::remove_file(root.join("locks").join(".size"));
244    let _ = guard.set_modified(SystemTime::now());
245    // Held until deletion ends, so no pin is made while blobs are deleted.
246    let gate = pin_gate(root)?;
247    gate.lock_exclusive()?;
248    let now = SystemTime::now();
249    let age = |metadata: &fs::Metadata| {
250        metadata
251            .modified()
252            .ok()
253            .and_then(|modified| now.duration_since(modified).ok())
254            .unwrap_or_default()
255    };
256    let mut entries = Vec::new();
257    let mut references: BTreeMap<String, usize> = BTreeMap::new();
258    // Warm state records cite blobs as entries do, and age out the same way.
259    let warm = fs::read_dir(root.join("warm")).into_iter().flatten();
260    for item in fs::read_dir(root.join("entries"))?.chain(warm) {
261        let item = item?;
262        let metadata = item.metadata()?;
263        // Unreadable manifests are misses already; they only take space.
264        let blobs = fs::read(item.path())
265            .ok()
266            .and_then(|bytes| crate::record::StoredRecord::read(&bytes).ok())
267            .map(|record| record.blobs())
268            .unwrap_or_default();
269        for blob in &blobs {
270            *references.entry(blob.clone()).or_default() += 1;
271        }
272        entries.push(Entry {
273            path: item.path(),
274            used: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
275            size: metadata.len(),
276            blobs,
277        });
278    }
279    // A pin is a reference no eviction removes.
280    for blob in pinned(root) {
281        *references.entry(blob).or_default() += 1;
282    }
283    let mut blob_sizes = BTreeMap::new();
284    let mut pruned = Pruned::default();
285    for item in fs::read_dir(root.join("blobs"))? {
286        let item = item?;
287        let metadata = item.metadata()?;
288        let name = item.file_name().to_string_lossy().into_owned();
289        if !references.contains_key(&name) && age(&metadata) > IN_FLIGHT {
290            if fs::remove_file(item.path()).is_ok() {
291                pruned.blobs += 1;
292                pruned.freed += metadata.len();
293            }
294            continue;
295        }
296        blob_sizes.insert(name, metadata.len());
297    }
298    let mut size: u64 =
299        entries.iter().map(|entry| entry.size).sum::<u64>() + blob_sizes.values().sum::<u64>();
300    entries.sort_by_key(|entry| entry.used);
301    for entry in entries {
302        if size <= limit {
303            break;
304        }
305        if fs::remove_file(&entry.path).is_err() {
306            continue;
307        }
308        pruned.entries += 1;
309        pruned.freed += entry.size;
310        size -= entry.size;
311        for blob in entry.blobs {
312            let count = references.get_mut(&blob).expect("counted above");
313            *count -= 1;
314            if *count == 0
315                && let Some(blob_size) = blob_sizes.remove(&blob)
316                && fs::remove_file(root.join("blobs").join(&blob)).is_ok()
317            {
318                pruned.blobs += 1;
319                pruned.freed += blob_size;
320                size -= blob_size;
321            }
322        }
323    }
324    drop(gate);
325    // Scratch space of runs that ended without cleaning up.
326    for item in fs::read_dir(root.join("tmp"))
327        .into_iter()
328        .flatten()
329        .flatten()
330    {
331        if item
332            .metadata()
333            .is_ok_and(|metadata| age(&metadata) > IN_FLIGHT)
334        {
335            let _ = fs::remove_dir_all(item.path()).or_else(|_| fs::remove_file(item.path()));
336        }
337    }
338    // Locks of keys without an entry, unless another run holds them.
339    for item in fs::read_dir(root.join("locks"))?.flatten() {
340        let name = item.file_name().to_string_lossy().into_owned();
341        if name.starts_with('.') || root.join("entries").join(format!("{name}.json")).exists() {
342            continue;
343        }
344        if item
345            .metadata()
346            .is_ok_and(|metadata| age(&metadata) > IN_FLIGHT)
347            && let Ok(file) = File::open(item.path())
348            && file.try_lock_exclusive().is_ok()
349        {
350            let _ = fs::remove_file(item.path());
351        }
352    }
353    pruned.size = if automatic { cached_size(root)? } else { size };
354    Ok(pruned)
355}
356
357// Recent timestamps may have coarse resolution on the backing filesystem.
358const STAMP_SETTLE: Duration = Duration::from_secs(2);
359
360#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
361struct DirectoryStamp {
362    modified: SystemTime,
363    len: u64,
364    #[cfg(unix)]
365    identity: (u64, u64, i64, i64),
366}
367
368#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
369struct SizeMemo {
370    measured: SystemTime,
371    stamps: Vec<Option<DirectoryStamp>>,
372    size: u64,
373}
374
375fn directory_stamps(root: &Path) -> Result<Vec<Option<DirectoryStamp>>> {
376    ["entries", "blobs", "warm"]
377        .iter()
378        .map(|name| {
379            let metadata = match fs::metadata(root.join(name)) {
380                Ok(metadata) => metadata,
381                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
382                Err(error) => return Err(error.into()),
383            };
384            #[cfg(unix)]
385            use std::os::unix::fs::MetadataExt;
386            Ok(Some(DirectoryStamp {
387                modified: metadata.modified()?,
388                len: metadata.len(),
389                #[cfg(unix)]
390                identity: (
391                    metadata.dev(),
392                    metadata.ino(),
393                    metadata.ctime(),
394                    metadata.ctime_nsec(),
395                ),
396            }))
397        })
398        .collect()
399}
400
401fn settled(stamps: &[Option<DirectoryStamp>], now: SystemTime) -> bool {
402    stamps.iter().flatten().all(|stamp| {
403        let old_enough = |time| {
404            now.duration_since(time)
405                .is_ok_and(|age| age >= STAMP_SETTLE)
406        };
407        if !old_enough(stamp.modified) {
408            return false;
409        }
410        #[cfg(unix)]
411        {
412            let (_, _, seconds, nanos) = stamp.identity;
413            if seconds < 0 || !(0..1_000_000_000).contains(&nanos) {
414                return false;
415            }
416            let changed = SystemTime::UNIX_EPOCH + Duration::new(seconds as u64, nanos as u32);
417            if !old_enough(changed) {
418                return false;
419            }
420        }
421        true
422    })
423}
424
425fn cached_size(root: &Path) -> Result<u64> {
426    let memo_path = root.join("locks").join(".size");
427    let now = SystemTime::now();
428    let before = directory_stamps(root)?;
429    if settled(&before, now)
430        && let Some(memo) = fs::read(&memo_path)
431            .ok()
432            .and_then(|bytes| serde_json::from_slice::<SizeMemo>(&bytes).ok())
433        && now
434            .duration_since(memo.measured)
435            .is_ok_and(|age| age < IN_FLIGHT)
436        && memo.stamps == before
437    {
438        return Ok(memo.size);
439    }
440    let size = ["entries", "blobs", "warm"]
441        .iter()
442        .zip(&before)
443        .filter(|(_, stamp)| stamp.is_some())
444        .try_fold(0u64, |total, (name, _)| {
445            directory_size(&root.join(name)).map(|size| total.saturating_add(size))
446        })?;
447    let after = directory_stamps(root)?;
448    if before == after && settled(&after, now) {
449        let memo = SizeMemo {
450            measured: now,
451            stamps: after,
452            size,
453        };
454        // Concurrent writers can replace this memo safely: readers still compare
455        // its stamps with the current directories. Partial writes are misses.
456        let mut temporary = tempfile::NamedTempFile::new_in(root.join("locks"))?;
457        serde_json::to_writer(temporary.as_file_mut(), &memo)?;
458        temporary.persist(&memo_path).map_err(|error| error.error)?;
459    } else {
460        let _ = fs::remove_file(memo_path);
461    }
462    Ok(size)
463}
464
465fn directory_size(directory: &Path) -> Result<u64> {
466    let mut size = 0;
467    for item in fs::read_dir(directory)? {
468        size += item?.metadata()?.len();
469    }
470    Ok(size)
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn fixture() -> tempfile::TempDir {
478        let root = tempfile::tempdir().unwrap();
479        for name in ["entries", "blobs", "locks"] {
480            fs::create_dir(root.path().join(name)).unwrap();
481        }
482        fs::write(root.path().join("entries/a.json"), b"bad record").unwrap();
483        fs::write(root.path().join("blobs/a"), [0; 100]).unwrap();
484        fs::write(root.path().join("locks/.prune"), []).unwrap();
485        root
486    }
487
488    /// A cache whose one entry cites one blob, dated two hours ago.
489    fn cited() -> (tempfile::TempDir, String) {
490        let root = fixture();
491        let blob = "b".repeat(64);
492        fs::write(root.path().join("blobs").join(&blob), [0; 100]).unwrap();
493        fs::write(
494            root.path().join("entries/a.json"),
495            serde_json::to_vec(&serde_json::json!({"log": blob})).unwrap(),
496        )
497        .unwrap();
498        let old = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
499        for path in [format!("blobs/{blob}"), "entries/a.json".into()] {
500            File::options()
501                .write(true)
502                .open(root.path().join(path))
503                .unwrap()
504                .set_modified(old)
505                .unwrap();
506        }
507        (root, blob)
508    }
509
510    /// Prunes until `blob` is gone. A prune finds `.prune` held, and does
511    /// nothing, while a child another test is starting has not yet exec'd
512    /// away its copy of the lock.
513    fn prune_away(root: &Path, limit: u64, blob: &str) {
514        let deadline = std::time::Instant::now() + Duration::from_secs(5);
515        while root.join("blobs").join(blob).exists() && std::time::Instant::now() < deadline {
516            prune(root, limit).unwrap();
517            std::thread::sleep(Duration::from_millis(10));
518        }
519        assert!(!root.join("blobs").join(blob).exists());
520    }
521
522    #[test]
523    fn a_pinned_blob_survives_evicting_the_last_entry_that_cites_it() {
524        let (root, blob) = cited();
525        let root = root.path();
526        let pin = pin(root, &BTreeSet::from([blob.clone()])).unwrap();
527        assert_eq!(prune(root, 0).unwrap().entries, 1);
528        assert!(root.join("blobs").join(&blob).exists());
529        drop(pin);
530        prune_away(root, 0, &blob);
531    }
532
533    #[test]
534    fn a_pinned_old_blob_no_entry_cites_survives() {
535        let (root, blob) = cited();
536        let root = root.path();
537        fs::remove_file(root.join("entries/a.json")).unwrap();
538        let pin = pin(root, &BTreeSet::from([blob.clone()])).unwrap();
539        prune(root, 1 << 30).unwrap();
540        assert!(root.join("blobs").join(&blob).exists());
541        drop(pin);
542        assert!(
543            fs::read_dir(pins(root))
544                .unwrap()
545                .all(|item| { item.unwrap().file_name() == ".gate" })
546        );
547        prune_away(root, 1 << 30, &blob);
548    }
549
550    #[test]
551    fn an_abandoned_pin_neither_keeps_blobs_nor_stays() {
552        let (root, blob) = cited();
553        let root = root.path();
554        fs::create_dir_all(pins(root)).unwrap();
555        fs::write(pins(root).join("gone.lock"), []).unwrap();
556        fs::write(pins(root).join("gone.blobs"), format!("{blob}\n")).unwrap();
557        prune(root, 0).unwrap();
558        assert!(!root.join("blobs").join(&blob).exists());
559        assert!(!pins(root).join("gone.lock").exists());
560        assert!(!pins(root).join("gone.blobs").exists());
561    }
562
563    #[test]
564    fn a_pin_waits_for_a_prune_that_is_deleting() {
565        let (root, blob) = cited();
566        let root = root.path().to_owned();
567        let gate = pin_gate(&root).unwrap();
568        gate.lock_exclusive().unwrap();
569        let (sender, receiver) = std::sync::mpsc::channel();
570        let pinning = std::thread::spawn({
571            let root = root.clone();
572            move || {
573                let pin = pin(&root, &BTreeSet::from([blob])).unwrap();
574                sender.send(()).unwrap();
575                pin
576            }
577        });
578        assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
579        drop(gate);
580        receiver.recv_timeout(Duration::from_secs(5)).unwrap();
581        drop(pinning.join().unwrap());
582    }
583
584    #[test]
585    fn automatic_size_memo_reuses_and_reconciles() {
586        let root = fixture();
587        let root = root.path();
588        // No memo can be trusted while directory timestamps are recent.
589        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
590        assert!(!root.join("locks/.size").exists());
591        std::thread::sleep(STAMP_SETTLE + Duration::from_millis(100));
592        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
593        assert!(root.join("locks/.size").exists());
594
595        // Unsupported external in-place changes intentionally demonstrate that
596        // the automatic path reads only directory stamps, rather than each file.
597        fs::write(root.join("blobs/a"), [0; 200]).unwrap();
598        touch(&root.join("entries/a.json"));
599        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
600        assert_eq!(prune(root, 1_000).unwrap().size, 210);
601        assert!(!root.join("locks/.size").exists());
602
603        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
604        fs::write(root.join("locks/.size"), b"corrupt").unwrap();
605        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
606        let mut memo: SizeMemo =
607            serde_json::from_slice(&fs::read(root.join("locks/.size")).unwrap()).unwrap();
608        memo.measured = SystemTime::UNIX_EPOCH;
609        memo.size = 0;
610        fs::write(root.join("locks/.size"), serde_json::to_vec(&memo).unwrap()).unwrap();
611        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
612
613        // An atomic publication invalidates accounting immediately.
614        let temp = root.join("entries/new.tmp");
615        fs::write(&temp, [0; 30]).unwrap();
616        fs::rename(temp, root.join("entries/new.json")).unwrap();
617        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 240);
618        fs::create_dir(root.join("warm")).unwrap();
619        fs::write(root.join("warm/a.json"), [0; 40]).unwrap();
620        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 280);
621        std::thread::sleep(STAMP_SETTLE + Duration::from_millis(100));
622        assert_eq!(prune_automatic(root, 1_000).unwrap().size, 280);
623        assert!(root.join("locks/.size").exists());
624        // A lower limit must still trigger eviction with an unchanged memo.
625        let result = prune_automatic(root, 250).unwrap();
626        assert!(result.entries > 0);
627        assert!(result.size <= 250);
628    }
629
630    #[test]
631    fn old_prune_marker_forces_full_reconciliation() {
632        let root = fixture();
633        let root = root.path();
634        let marker = File::options()
635            .write(true)
636            .open(root.join("locks/.prune"))
637            .unwrap();
638        marker.set_modified(SystemTime::UNIX_EPOCH).unwrap();
639        let result = prune_automatic(root, 100).unwrap();
640        assert_eq!(result.entries, 1);
641        assert_eq!(result.size, 100);
642    }
643
644    #[test]
645    fn parses_sizes_like_nx() {
646        assert_eq!(parse_size("1024").unwrap(), 1024);
647        assert_eq!(parse_size("1KB").unwrap(), 1024);
648        assert_eq!(parse_size("1.5 MB").unwrap(), 1_572_864);
649        assert_eq!(parse_size("2GB").unwrap(), 2 << 30);
650        assert_eq!(parse_size("0").unwrap(), 0);
651        for bad in ["", "1TB", "1.2.3MB", "MB", "-1"] {
652            assert!(parse_size(bad).is_err(), "{bad}");
653        }
654    }
655}