Skip to main content

prov_fixity/
cache.rs

1//! A device-local memory of what each file in a workspace hashed to last time.
2//!
3//! [`digest`](super::digest) is the cheapest thing in prov to describe and one
4//! of the most expensive to run: it reads a whole file and pushes every byte
5//! through SHA-256. A [`history_capture`] does that for *every* file in the
6//! capture set, on every capture, whether or not anything changed — so a
7//! workspace where one document was edited pays to read and hash the other
8//! nine hundred to find that out.
9//!
10//! Almost none of them changed. So remember what they hashed to, and check
11//! rather than read. Validating an entry costs one stat; a capture over a
12//! workspace where nothing changed does no reads and no hashing at all.
13//!
14//! ## Why this may serve a capture and may never serve `check`
15//!
16//! An entry is served only when the file's modification time *and* its length
17//! both still match what was recorded — the test every build system trusts. But
18//! prov has a pass whose entire purpose is to detect changes that test cannot
19//! see: [`fixity_findings`], the bit-rot check. Silent corruption is by
20//! construction a change to the bytes that does not touch the inode's mtime or
21//! length — a disk flipping a bit does not restat the file. A cache keyed on
22//! mtime would confidently vouch for precisely the file that rotted.
23//!
24//! So the line is drawn in the callers, not here:
25//!
26//! > A remembered digest may **decide what to do**, and it may land somewhere
27//! > content-addressed. It may never **establish or verify a fixity baseline**.
28//!
29//! [`history_capture`] is on the safe side of that line, and its own use is
30//! narrower still: a remembered digest is used only when the blob it names is
31//! *already parked*, so the bytes at that address are on disk and were hashed
32//! from the real file when they got there. Whenever a capture actually reads a
33//! file, it hashes the bytes it read. A stale entry can therefore cost an event
34//! that misdescribes an instant; it can never park bytes under an address that
35//! is not their digest, and it can never make `check` miss corruption — because
36//! `check` does not ask.
37//!
38//! ## Why device-local, and not in the workspace
39//!
40//! A prov workspace is an archive of plain files that explains itself. A binary
41//! cache is not part of that explanation, and two devices writing one would
42//! produce sync conflicts over a file whose only job is to describe *this*
43//! device's disk. It is also *derived state* in the sense DESIGN §5 means:
44//! disposable, rebuildable, and load-bearing for nothing. Deleting it costs one
45//! slow capture.
46//!
47//! Which is why this type does no I/O. It decodes from bytes and encodes to
48//! bytes; where those bytes live is the host's business (`prov-cli` keeps them
49//! under the user's cache directory), and prov itself stays free of any notion
50//! of a location outside the workspace.
51//!
52//! ## Staleness
53//!
54//! Every failure mode — a missing file, a truncated one, the wrong magic, a
55//! version this build does not know, a cache written for a different workspace —
56//! decodes to the same answer: nothing is remembered. There is nothing in here
57//! worth recovering, only re-deriving. And because the validator is the file's
58//! own stat, *any* write by anyone — prov, an editor, a sync daemon — retires
59//! the entry on its own. [`forget`](FixityCache::forget) is prov being tidy
60//! about its own writes, not the mechanism that keeps this honest.
61//!
62//! The capture and check operations that consume this cache live in the
63//! higher-level `prov` crate.
64
65use std::collections::BTreeMap;
66use std::path::{Path, PathBuf};
67use std::time::UNIX_EPOCH;
68
69use prov_graph::fs::Metadata;
70
71/// Identifies the format, so a file written by another program is refused
72/// rather than misread.
73const MAGIC: &[u8; 8] = b"PROVFIXC";
74
75/// Bumped whenever the layout below changes. A file at a version this build does
76/// not know is discarded, not migrated — it is a cache.
77const VERSION: u32 = 1;
78
79/// The most files one cache may remember. Beyond this it stops growing rather
80/// than becoming an index of the disk; the excess is re-hashed next time.
81const MAX_ENTRIES: usize = 200_000;
82
83/// One remembered file: the stat it was hashed at, and what it hashed to.
84#[derive(Debug, Clone)]
85struct Entry {
86    /// Modification time in nanoseconds either side of the Unix epoch.
87    ///
88    /// Nanoseconds rather than the milliseconds a cache like this usually keeps,
89    /// because the whole risk here is two different contents sharing one
90    /// timestamp *and* one length, and the width of that window is the width of
91    /// the clock's resolution. Signed, so a file stamped before 1970 — which a
92    /// restored archive genuinely can be — records its real time instead of
93    /// saturating at the epoch and colliding with everything else that did.
94    mtime_ns: i128,
95    len: u64,
96    /// The digest, in [`digest`](super::digest)'s self-describing
97    /// `sha256:<hex>` spelling. Stored as written rather than as raw bytes, so a
98    /// future algorithm needs no format change and an entry this build cannot
99    /// interpret is still legible to one that can.
100    hash: String,
101}
102
103/// What this device remembers of a workspace's file digests.
104///
105/// Keyed by **workspace-relative path**, so a workspace that moves keeps its
106/// cache; `root` is recorded only to refuse a cache that was written for a
107/// different workspace entirely.
108#[derive(Debug, Clone)]
109pub struct FixityCache {
110    root: PathBuf,
111    entries: BTreeMap<PathBuf, Entry>,
112    /// Whether anything has changed since it was decoded — a capture that
113    /// learned nothing should not rewrite the file to say so.
114    dirty: bool,
115}
116
117impl FixityCache {
118    /// An empty cache for the workspace rooted at `root`.
119    pub fn new(root: impl Into<PathBuf>) -> Self {
120        Self {
121            root: root.into(),
122            entries: BTreeMap::new(),
123            dirty: false,
124        }
125    }
126
127    /// Decode what was persisted for the workspace at `root`.
128    ///
129    /// `None` for anything that is not exactly what some build of prov wrote
130    /// for *this* workspace — the caller's move is to start
131    /// [`new`](Self::new), never to investigate.
132    pub fn decode(bytes: &[u8], root: &Path) -> Option<Self> {
133        let mut r = Reader { bytes, at: 0 };
134        if r.take(MAGIC.len())? != MAGIC {
135            return None;
136        }
137        if r.u32()? != VERSION {
138            return None;
139        }
140        let stored_root = r.string()?;
141        if Path::new(&stored_root) != root {
142            // Written for a workspace at a different path. Its entries may well
143            // still describe real files, but nothing here proves it, and a wrong
144            // guess would serve one workspace's digests as another's.
145            return None;
146        }
147        let count = r.u32()? as usize;
148        let mut entries = BTreeMap::new();
149        for _ in 0..count {
150            let rel = r.string()?;
151            let mtime_ns = r.i128()?;
152            let len = r.u64()?;
153            let hash = r.string()?;
154            entries.insert(
155                PathBuf::from(rel),
156                Entry {
157                    mtime_ns,
158                    len,
159                    hash,
160                },
161            );
162        }
163        Some(Self {
164            root: root.to_path_buf(),
165            entries,
166            dirty: false,
167        })
168    }
169
170    /// The bytes to persist. Pair with [`is_dirty`](Self::is_dirty): a cache
171    /// that learned nothing this run is worth writing to nobody.
172    pub fn encode(&self) -> Vec<u8> {
173        let mut out = Vec::with_capacity(self.entries.len() * 128 + 64);
174        out.extend_from_slice(MAGIC);
175        out.extend_from_slice(&VERSION.to_le_bytes());
176        push_str(&mut out, &self.root.to_string_lossy());
177        out.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
178        // `BTreeMap`, so the bytes are a function of the contents and not of the
179        // order they were learned in — two runs that saw the same workspace
180        // write the same file.
181        for (rel, entry) in &self.entries {
182            push_str(&mut out, &rel.to_string_lossy());
183            out.extend_from_slice(&entry.mtime_ns.to_le_bytes());
184            out.extend_from_slice(&entry.len.to_le_bytes());
185            push_str(&mut out, &entry.hash);
186        }
187        out
188    }
189
190    /// The remembered digest for the workspace-relative `path`, if the file
191    /// `meta` describes is still the one it was recorded against.
192    ///
193    /// Both halves of the stat must agree: a length alone misses an edit that
194    /// preserved the size, and a timestamp alone trusts a clock the file may
195    /// have arrived with.
196    pub fn get(&self, path: &Path, meta: &Metadata) -> Option<&str> {
197        let stamp = stamp(meta)?;
198        let entry = self.entries.get(path)?;
199        (entry.mtime_ns == stamp && entry.len == meta.len()).then_some(entry.hash.as_str())
200    }
201
202    /// Remember that `path` hashed to `hash` at the stat `meta` describes.
203    ///
204    /// Three things are declined rather than stored wrong: a file whose backend
205    /// reports no modification time (nothing could ever validate it, so keeping
206    /// it would only cost space), a path that is not valid UTF-8 (it has no
207    /// stable key, and a lossy one could collide with a different file), and
208    /// anything at all once [`MAX_ENTRIES`] is reached.
209    pub fn put(&mut self, path: &Path, meta: &Metadata, hash: &str) {
210        let Some(mtime_ns) = stamp(meta) else { return };
211        if path.to_str().is_none() || hash.is_empty() {
212            return;
213        }
214        let entry = Entry {
215            mtime_ns,
216            len: meta.len(),
217            hash: hash.to_string(),
218        };
219        match self.entries.get_mut(path) {
220            Some(slot) => {
221                if slot.mtime_ns == entry.mtime_ns
222                    && slot.len == entry.len
223                    && slot.hash == entry.hash
224                {
225                    return;
226                }
227                *slot = entry;
228            }
229            None => {
230                if self.entries.len() >= MAX_ENTRIES {
231                    return;
232                }
233                self.entries.insert(path.to_path_buf(), entry);
234            }
235        }
236        self.dirty = true;
237    }
238
239    /// Forget `path` — what a write to it means.
240    pub fn forget(&mut self, path: &Path) {
241        if self.entries.remove(path).is_some() {
242            self.dirty = true;
243        }
244    }
245
246    /// Forget everything. For a write prov cannot attribute to one path.
247    pub fn clear(&mut self) {
248        if !self.entries.is_empty() {
249            self.entries.clear();
250            self.dirty = true;
251        }
252    }
253
254    /// Whether anything has changed since this was decoded.
255    pub fn is_dirty(&self) -> bool {
256        self.dirty
257    }
258
259    /// The workspace this cache was built for.
260    pub fn root(&self) -> &Path {
261        &self.root
262    }
263
264    /// How many files are remembered.
265    pub fn len(&self) -> usize {
266        self.entries.len()
267    }
268
269    /// Whether nothing is remembered.
270    pub fn is_empty(&self) -> bool {
271        self.entries.is_empty()
272    }
273}
274
275/// A file's modification time as nanoseconds either side of the Unix epoch, or
276/// `None` when the backend does not report one.
277fn stamp(meta: &Metadata) -> Option<i128> {
278    let modified = meta.modified().ok()?;
279    Some(match modified.duration_since(UNIX_EPOCH) {
280        Ok(since) => since.as_nanos() as i128,
281        // Before the epoch — a real state for a restored archive, and one that
282        // must stay distinguishable rather than clamping to zero.
283        Err(before) => -(before.duration().as_nanos() as i128),
284    })
285}
286
287fn push_str(out: &mut Vec<u8>, s: &str) {
288    out.extend_from_slice(&(s.len() as u32).to_le_bytes());
289    out.extend_from_slice(s.as_bytes());
290}
291
292/// A bounds-checked cursor. Every read returns `None` past the end, so a
293/// truncated file falls out as "no cache" rather than a panic.
294struct Reader<'a> {
295    bytes: &'a [u8],
296    at: usize,
297}
298
299impl<'a> Reader<'a> {
300    fn take(&mut self, n: usize) -> Option<&'a [u8]> {
301        let end = self.at.checked_add(n)?;
302        let slice = self.bytes.get(self.at..end)?;
303        self.at = end;
304        Some(slice)
305    }
306    fn u32(&mut self) -> Option<u32> {
307        Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
308    }
309    fn u64(&mut self) -> Option<u64> {
310        Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
311    }
312    fn i128(&mut self) -> Option<i128> {
313        Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?))
314    }
315    fn string(&mut self) -> Option<String> {
316        let len = self.u32()? as usize;
317        String::from_utf8(self.take(len)?.to_vec()).ok()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use prov_graph::fs::FileType;
325    use std::time::Duration;
326
327    fn meta(secs: u64, len: u64) -> Metadata {
328        Metadata::new(
329            FileType::FILE,
330            len,
331            Some(UNIX_EPOCH + Duration::from_secs(secs)),
332        )
333    }
334
335    /// A backend that reports no modification time — `InMemoryFs` is one.
336    fn timeless(len: u64) -> Metadata {
337        Metadata::new(FileType::FILE, len, None)
338    }
339
340    const HASH: &str = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
341
342    #[test]
343    fn what_was_remembered_survives_a_round_trip() {
344        let root = Path::new("/vault");
345        let mut cache = FixityCache::new(root);
346        cache.put(Path::new("index.md"), &meta(1_700_000_000, 5), HASH);
347        cache.put(
348            Path::new("notes/a.md"),
349            &meta(1_700_000_001, 9),
350            "sha256:beef",
351        );
352
353        let bytes = cache.encode();
354        let reloaded = FixityCache::decode(&bytes, root).unwrap();
355        assert_eq!(reloaded.len(), 2);
356        assert_eq!(
357            reloaded.get(Path::new("index.md"), &meta(1_700_000_000, 5)),
358            Some(HASH)
359        );
360        assert_eq!(
361            reloaded.get(Path::new("notes/a.md"), &meta(1_700_000_001, 9)),
362            Some("sha256:beef")
363        );
364        assert!(!reloaded.is_dirty(), "a freshly decoded cache is not dirty");
365    }
366
367    /// The whole safety argument in one test: an entry is served only while both
368    /// halves of the stat still agree.
369    #[test]
370    fn a_changed_file_is_not_served_from_the_cache() {
371        let mut cache = FixityCache::new("/vault");
372        let path = Path::new("index.md");
373        cache.put(path, &meta(1_700_000_000, 5), HASH);
374
375        assert!(cache.get(path, &meta(1_700_000_000, 5)).is_some());
376        assert!(
377            cache.get(path, &meta(1_700_000_001, 5)).is_none(),
378            "a newer modification time is a different file"
379        );
380        assert!(
381            cache.get(path, &meta(1_700_000_000, 6)).is_none(),
382            "a different length is a different file"
383        );
384        assert!(
385            cache.get(path, &timeless(5)).is_none(),
386            "a backend that cannot say when is never trusted"
387        );
388    }
389
390    #[test]
391    fn a_file_with_no_modification_time_is_never_remembered() {
392        let mut cache = FixityCache::new("/vault");
393        cache.put(Path::new("index.md"), &timeless(5), HASH);
394        assert_eq!(cache.len(), 0);
395        assert!(!cache.is_dirty());
396    }
397
398    #[test]
399    fn a_write_forgets_the_file_it_wrote() {
400        let mut cache = FixityCache::new("/vault");
401        let path = Path::new("index.md");
402        cache.put(path, &meta(1, 5), HASH);
403        cache.forget(path);
404        assert!(cache.get(path, &meta(1, 5)).is_none());
405        assert_eq!(cache.len(), 0);
406    }
407
408    /// A cache is not a database. Every way it can be wrong reads as nothing
409    /// remembered.
410    #[test]
411    fn a_damaged_or_foreign_cache_decodes_to_nothing() {
412        let root = Path::new("/vault");
413        let mut cache = FixityCache::new(root);
414        cache.put(Path::new("index.md"), &meta(1, 5), HASH);
415        let good = cache.encode();
416
417        assert!(
418            FixityCache::decode(&good[..good.len() - 3], root).is_none(),
419            "a truncated cache was read anyway"
420        );
421
422        let mut wrong_magic = good.clone();
423        wrong_magic[0] = b'X';
424        assert!(FixityCache::decode(&wrong_magic, root).is_none());
425
426        let mut wrong_version = good.clone();
427        wrong_version[MAGIC.len()] = 0xff;
428        assert!(FixityCache::decode(&wrong_version, root).is_none());
429
430        assert!(
431            FixityCache::decode(&good, Path::new("/elsewhere")).is_none(),
432            "a cache written for another workspace was accepted"
433        );
434
435        assert!(FixityCache::decode(b"", root).is_none());
436    }
437
438    /// A capture that learned nothing must not rewrite the file to say so.
439    #[test]
440    fn re_recording_the_same_answer_leaves_the_cache_clean() {
441        let root = Path::new("/vault");
442        let mut cache = FixityCache::new(root);
443        cache.put(Path::new("index.md"), &meta(1, 5), HASH);
444        let mut reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
445
446        reloaded.put(Path::new("index.md"), &meta(1, 5), HASH);
447        assert!(
448            !reloaded.is_dirty(),
449            "recording an answer already held marked the cache dirty"
450        );
451
452        reloaded.put(Path::new("index.md"), &meta(2, 5), "sha256:beef");
453        assert!(
454            reloaded.is_dirty(),
455            "a genuinely new answer was not recorded"
456        );
457    }
458
459    /// The encoding is a function of the contents, not of the order they arrived
460    /// in — so an unchanged workspace produces an unchanged file.
461    #[test]
462    fn the_encoding_is_order_independent() {
463        let mut one = FixityCache::new("/vault");
464        one.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
465        one.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
466
467        let mut two = FixityCache::new("/vault");
468        two.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
469        two.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
470
471        assert_eq!(one.encode(), two.encode());
472    }
473
474    /// A pre-epoch timestamp is a real state for a restored archive, and two of
475    /// them must stay distinguishable from each other and from the epoch.
476    #[test]
477    fn a_pre_epoch_timestamp_round_trips() {
478        let root = Path::new("/vault");
479        let old = Metadata::new(
480            FileType::FILE,
481            5,
482            Some(UNIX_EPOCH - Duration::from_secs(86_400)),
483        );
484        let older = Metadata::new(
485            FileType::FILE,
486            5,
487            Some(UNIX_EPOCH - Duration::from_secs(172_800)),
488        );
489
490        let mut cache = FixityCache::new(root);
491        cache.put(Path::new("relic.md"), &old, HASH);
492        let reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
493
494        assert_eq!(reloaded.get(Path::new("relic.md"), &old), Some(HASH));
495        assert!(
496            reloaded.get(Path::new("relic.md"), &older).is_none(),
497            "two pre-epoch timestamps collapsed onto one another"
498        );
499        assert!(
500            reloaded.get(Path::new("relic.md"), &meta(0, 5)).is_none(),
501            "a pre-epoch timestamp was clamped to the epoch"
502        );
503    }
504
505    /// Laws over the frame, rather than examples of it.
506    ///
507    /// This is the crate's one hand-rolled binary parser, and it reads a file
508    /// prov did not necessarily write: it lives outside the workspace, in a
509    /// user cache directory, where a half-finished write, a truncating backup,
510    /// or an unrelated file of the same name are all ordinary. The module's
511    /// promise is absolute — "`None` for anything that is not exactly what some
512    /// build of prov wrote for *this* workspace", with **every failure decoding
513    /// to nothing remembered** — and a length prefix read straight out of
514    /// untrusted bytes is exactly where that sort of promise usually has a hole.
515    ///
516    /// A parser is also the one place property testing most resembles fuzzing,
517    /// so both are here, and the difference between them is the lesson:
518    /// uniformly random bytes almost never get past `MAGIC`, so they prove only
519    /// that the front door is locked. *Corrupting a valid encoding* keeps the
520    /// header intact and lands the damage in a length prefix or a UTF-8
521    /// boundary — the code that never runs otherwise.
522    mod properties {
523        use super::*;
524        use proptest::prelude::*;
525
526        const ROOT: &str = "/vault";
527
528        /// A cache built the only way one ever is: through `put`.
529        fn cache() -> impl Strategy<Value = FixityCache> {
530            prop::collection::vec(
531                (
532                    "[a-z/]{1,8}",
533                    0..4_000_000_000u64,
534                    0..64u64,
535                    "[a-f0-9]{0,8}",
536                ),
537                0..5usize,
538            )
539            .prop_map(|puts| {
540                let mut cache = FixityCache::new(ROOT);
541                for (path, secs, len, hash) in puts {
542                    cache.put(
543                        Path::new(&path),
544                        &meta(secs, len),
545                        &format!("sha256:{hash}"),
546                    );
547                }
548                cache
549            })
550        }
551
552        proptest! {
553            /// `decode ∘ encode = id`. The entries survive, the root survives,
554            /// and the reloaded cache is **not dirty** — the last clause is the
555            /// one with consequences, since a cache that decoded itself dirty
556            /// would rewrite the file on every run that learned nothing.
557            #[test]
558            fn what_was_encoded_decodes_back_to_the_same_cache(cache in cache()) {
559                let bytes = cache.encode();
560                let reloaded = FixityCache::decode(&bytes, Path::new(ROOT))
561                    .expect("prov's own bytes must decode");
562                prop_assert_eq!(reloaded.len(), cache.len());
563                prop_assert_eq!(reloaded.root(), cache.root());
564                prop_assert!(!reloaded.is_dirty());
565                // Encoding is a function of the contents, not of the order they
566                // were learned in — which is what makes the file diffable and
567                // two runs over one workspace agree byte for byte.
568                prop_assert_eq!(reloaded.encode(), bytes);
569            }
570
571            /// Arbitrary bytes: never a panic, and never a cache claiming a
572            /// root other than the one asked for. This is the front-door test —
573            /// it rarely gets past `MAGIC`, which is exactly why the next one
574            /// exists.
575            #[test]
576            fn arbitrary_bytes_decode_to_nothing_or_to_this_workspace(
577                bytes in prop::collection::vec(any::<u8>(), 0..96),
578            ) {
579                if let Some(cache) = FixityCache::decode(&bytes, Path::new(ROOT)) {
580                    prop_assert_eq!(cache.root(), Path::new(ROOT));
581                    prop_assert!(!cache.is_dirty());
582                }
583            }
584
585            /// **Corrupt one byte of a real encoding.** The header still passes,
586            /// so the damage lands in a length prefix, a UTF-8 sequence, or an
587            /// entry count — the paths random bytes never reach.
588            ///
589            /// What is *not* claimed: that corruption is detected. There is no
590            /// per-entry checksum, deliberately, because a wrong digest here can
591            /// only ever be served past the mtime-and-length gate the entry also
592            /// carries. The claim is the weaker, sufficient one: no panic, no
593            /// hang, and no cache attributed to the wrong workspace.
594            #[test]
595            fn a_corrupted_encoding_never_panics_and_never_changes_workspace(
596                cache in cache(),
597                at in any::<prop::sample::Index>(),
598                xor in 1..=255u8,
599            ) {
600                let mut bytes = cache.encode();
601                let at = at.index(bytes.len());
602                bytes[at] ^= xor;
603                if let Some(decoded) = FixityCache::decode(&bytes, Path::new(ROOT)) {
604                    prop_assert_eq!(decoded.root(), Path::new(ROOT));
605                    prop_assert!(!decoded.is_dirty());
606                }
607            }
608
609            /// **Truncation is never partial acceptance.** A short read — an
610            /// interrupted write, a copy that stopped — must decode to nothing,
611            /// not to the entries that happened to arrive. Anything less would
612            /// let a half-written cache answer questions about files whose
613            /// records never landed.
614            #[test]
615            fn a_truncated_encoding_decodes_to_nothing(
616                cache in cache(),
617                at in any::<prop::sample::Index>(),
618            ) {
619                let bytes = cache.encode();
620                let cut = at.index(bytes.len());
621                prop_assert!(
622                    FixityCache::decode(&bytes[..cut], Path::new(ROOT)).is_none(),
623                    "{cut} of {} bytes still decoded",
624                    bytes.len()
625                );
626            }
627
628            /// A cache written for another workspace is refused outright, however
629            /// well-formed it is — the entries may describe real files, but
630            /// nothing in them proves which workspace's.
631            #[test]
632            fn a_cache_from_another_workspace_is_refused(cache in cache()) {
633                prop_assert!(
634                    FixityCache::decode(&cache.encode(), Path::new("/elsewhere")).is_none()
635                );
636            }
637        }
638    }
639}