Skip to main content

package_directory/
lib.rs

1// Copyright 2021 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5#![allow(clippy::let_unit_value)]
6
7use fidl::endpoints::ServerEnd;
8use fidl_fuchsia_io as fio;
9use std::collections::HashSet;
10use std::convert::TryInto as _;
11use std::future::Future;
12use vfs::common::send_on_open_with_error;
13use vfs::directory::entry::EntryInfo;
14use vfs::directory::entry_container::Directory;
15use vfs::{ObjectRequest, ObjectRequestRef};
16
17mod meta_as_dir;
18mod meta_subdir;
19mod non_meta_subdir;
20mod root_dir;
21mod root_dir_cache;
22
23pub use root_dir::{PathError, ReadFileError, RootDir, SubpackagesError};
24pub use root_dir_cache::{CachedRootDir, RootDirCache};
25pub use vfs::execution_scope::ExecutionScope;
26
27pub(crate) const DIRECTORY_ABILITIES: fio::Abilities =
28    fio::Abilities::GET_ATTRIBUTES.union(fio::Abilities::ENUMERATE).union(fio::Abilities::TRAVERSE);
29
30pub(crate) const ALLOWED_FLAGS: fio::Flags = fio::Flags::empty()
31    .union(fio::MASK_KNOWN_PROTOCOLS)
32    .union(fio::PERM_READABLE)
33    .union(fio::PERM_EXECUTABLE)
34    .union(fio::Flags::PERM_INHERIT_EXECUTE)
35    .union(fio::Flags::FLAG_SEND_REPRESENTATION);
36
37#[derive(thiserror::Error, Debug)]
38pub enum Error {
39    #[error("the meta.far was not found")]
40    MissingMetaFar,
41
42    #[error("while opening the meta.far")]
43    OpenMetaFar(#[source] NonMetaStorageError),
44
45    #[error("while instantiating a fuchsia archive reader")]
46    ArchiveReader(#[source] fuchsia_archive::Error),
47
48    #[error("meta.far has a path that is not valid utf-8: {path:?}")]
49    NonUtf8MetaEntry {
50        #[source]
51        source: std::str::Utf8Error,
52        path: Vec<u8>,
53    },
54
55    #[error("while reading meta/contents")]
56    ReadMetaContents(#[source] fuchsia_archive::Error),
57
58    #[error("while deserializing meta/contents")]
59    DeserializeMetaContents(#[source] fuchsia_pkg::MetaContentsError),
60
61    #[error("collision between a file and a directory at path: '{:?}'", path)]
62    FileDirectoryCollision { path: String },
63}
64
65impl From<&Error> for zx::Status {
66    fn from(e: &Error) -> Self {
67        use Error::*;
68        match e {
69            MissingMetaFar => zx::Status::NOT_FOUND,
70            OpenMetaFar(e) => e.into(),
71            ArchiveReader(fuchsia_archive::Error::Read(_)) => zx::Status::IO,
72            ArchiveReader(_) | ReadMetaContents(_) | DeserializeMetaContents(_) => {
73                zx::Status::INVALID_ARGS
74            }
75            FileDirectoryCollision { .. } | NonUtf8MetaEntry { .. } => zx::Status::INVALID_ARGS,
76        }
77    }
78}
79
80#[derive(thiserror::Error, Debug)]
81pub enum NonMetaStorageError {
82    #[error("while reading blob")]
83    ReadBlob(#[source] fuchsia_fs::file::ReadError),
84
85    #[error("while opening blob")]
86    OpenBlob(#[source] fuchsia_fs::node::OpenError),
87
88    #[error("while making FIDL call")]
89    Fidl(#[source] fidl::Error),
90
91    #[error("while calling GetBackingMemory")]
92    GetVmo(#[source] zx::Status),
93}
94
95impl NonMetaStorageError {
96    pub fn is_not_found_error(&self) -> bool {
97        match self {
98            NonMetaStorageError::ReadBlob(e) => e.is_not_found_error(),
99            NonMetaStorageError::OpenBlob(e) => e.is_not_found_error(),
100            NonMetaStorageError::GetVmo(status) => *status == zx::Status::NOT_FOUND,
101            _ => false,
102        }
103    }
104}
105
106impl From<&NonMetaStorageError> for zx::Status {
107    fn from(e: &NonMetaStorageError) -> Self {
108        if e.is_not_found_error() { zx::Status::NOT_FOUND } else { zx::Status::INTERNAL }
109    }
110}
111
112/// The storage that provides the non-meta files (accessed by hash) of a package-directory (e.g.
113/// blobfs).
114pub trait NonMetaStorage: Send + Sync + Sized + 'static {
115    /// Open a non-meta file by hash. `scope` may complete while there are still open connections.
116    fn open(
117        &self,
118        _blob: &fuchsia_hash::Hash,
119        _flags: fio::Flags,
120        _scope: ExecutionScope,
121        _object_request: ObjectRequestRef<'_>,
122    ) -> Result<(), zx::Status>;
123
124    /// Get a read-only VMO for the blob.
125    fn get_blob_vmo(
126        &self,
127        hash: &fuchsia_hash::Hash,
128    ) -> impl Future<Output = Result<zx::Vmo, NonMetaStorageError>> + Send;
129
130    /// Reads the contents of a blob.
131    fn read_blob(
132        &self,
133        hash: &fuchsia_hash::Hash,
134    ) -> impl Future<Output = Result<Vec<u8>, NonMetaStorageError>> + Send;
135}
136
137impl NonMetaStorage for blobfs::Client {
138    fn open(
139        &self,
140        blob: &fuchsia_hash::Hash,
141        flags: fio::Flags,
142        scope: ExecutionScope,
143        object_request: ObjectRequestRef<'_>,
144    ) -> Result<(), zx::Status> {
145        self.open_blob_for_read(blob, flags, scope, object_request)
146    }
147
148    async fn get_blob_vmo(
149        &self,
150        hash: &fuchsia_hash::Hash,
151    ) -> Result<zx::Vmo, NonMetaStorageError> {
152        self.get_blob_vmo(hash).await.map_err(|e| match e {
153            blobfs::GetBlobVmoError::OpenBlob(e) => NonMetaStorageError::OpenBlob(e),
154            blobfs::GetBlobVmoError::GetVmo(e) => NonMetaStorageError::GetVmo(e),
155            blobfs::GetBlobVmoError::Fidl(e) => NonMetaStorageError::Fidl(e),
156        })
157    }
158
159    async fn read_blob(&self, hash: &fuchsia_hash::Hash) -> Result<Vec<u8>, NonMetaStorageError> {
160        let vmo = NonMetaStorage::get_blob_vmo(self, hash).await?;
161        let content_size = vmo.get_content_size().map_err(|e| {
162            NonMetaStorageError::ReadBlob(fuchsia_fs::file::ReadError::ReadError(e))
163        })?;
164        vmo.read_to_vec::<u8>(0, content_size)
165            .map_err(|e| NonMetaStorageError::ReadBlob(fuchsia_fs::file::ReadError::ReadError(e)))
166    }
167}
168
169/// Assumes the directory is a flat container and the files are named after their hashes.
170impl NonMetaStorage for fio::DirectoryProxy {
171    fn open(
172        &self,
173        blob: &fuchsia_hash::Hash,
174        flags: fio::Flags,
175        _scope: ExecutionScope,
176        object_request: ObjectRequestRef<'_>,
177    ) -> Result<(), zx::Status> {
178        // If the FIDL call passes, errors will be communicated via the `object_request` channel.
179        self.open(
180            blob.to_string().as_str(),
181            flags,
182            &object_request.options(),
183            object_request.take().into_channel(),
184        )
185        .map_err(|_fidl_error| zx::Status::PEER_CLOSED)
186    }
187
188    async fn get_blob_vmo(
189        &self,
190        hash: &fuchsia_hash::Hash,
191    ) -> Result<zx::Vmo, NonMetaStorageError> {
192        let proxy = fuchsia_fs::directory::open_file(self, &hash.to_string(), fio::PERM_READABLE)
193            .await
194            .map_err(NonMetaStorageError::OpenBlob)?;
195        proxy
196            .get_backing_memory(fio::VmoFlags::PRIVATE_CLONE | fio::VmoFlags::READ)
197            .await
198            .map_err(NonMetaStorageError::Fidl)?
199            .map_err(|e| NonMetaStorageError::GetVmo(zx::Status::err_from_raw(e)))
200    }
201
202    async fn read_blob(&self, hash: &fuchsia_hash::Hash) -> Result<Vec<u8>, NonMetaStorageError> {
203        fuchsia_fs::directory::read_file(self, &hash.to_string())
204            .await
205            .map_err(NonMetaStorageError::ReadBlob)
206    }
207}
208
209/// Serves a package directory for the package with hash `meta_far` on `server_end`.
210/// The connection rights are set by `flags`, used the same as the `flags` parameter of
211///   fuchsia.io/Directory.Open.
212pub fn serve(
213    scope: vfs::execution_scope::ExecutionScope,
214    non_meta_storage: impl NonMetaStorage,
215    meta_far: fuchsia_hash::Hash,
216    flags: fio::Flags,
217    server_end: ServerEnd<fio::DirectoryMarker>,
218) -> impl futures::Future<Output = Result<(), Error>> {
219    serve_path(
220        scope,
221        non_meta_storage,
222        meta_far,
223        flags,
224        vfs::Path::dot(),
225        server_end.into_channel().into(),
226    )
227}
228
229/// Serves a sub-`path` of a package directory for the package with hash `meta_far` on `server_end`.
230///
231/// The connection rights are set by `flags`, used the same as the `flags` parameter of
232///   fuchsia.io/Directory.Open.
233/// On error while loading the package metadata, closes the provided server end, sending an OnOpen
234///   response with an error status if requested.
235pub async fn serve_path(
236    scope: vfs::execution_scope::ExecutionScope,
237    non_meta_storage: impl NonMetaStorage,
238    meta_far: fuchsia_hash::Hash,
239    flags: fio::Flags,
240    path: vfs::Path,
241    server_end: ServerEnd<fio::NodeMarker>,
242) -> Result<(), Error> {
243    let root_dir = match RootDir::new(non_meta_storage, meta_far).await {
244        Ok(d) => d,
245        Err(e) => {
246            let () = send_on_open_with_error(
247                flags.contains(fio::Flags::FLAG_SEND_REPRESENTATION),
248                server_end,
249                (&e).into(),
250            );
251            return Err(e);
252        }
253    };
254
255    ObjectRequest::new(flags, &fio::Options::default(), server_end.into_channel())
256        .handle(|request| root_dir.open(scope, path, flags, request));
257    Ok(())
258}
259
260fn usize_to_u64_safe(u: usize) -> u64 {
261    let ret: u64 = u.try_into().unwrap();
262    static_assertions::assert_eq_size_val!(u, ret);
263    ret
264}
265
266/// Takes a directory hierarchy and a directory in the hierarchy and returns all the directory's
267/// children in alphabetical order.
268///   `materialized_tree`: object relative path expressions of every file in a directory hierarchy
269///   `dir`: the empty string (signifies the root dir) or a path to a subdir (must be an object
270///          relative path expression plus a trailing slash)
271/// Returns an empty vec if `dir` isn't in `materialized_tree`.
272fn get_dir_children<'a>(
273    materialized_tree: impl IntoIterator<Item = &'a str>,
274    dir: &str,
275) -> Vec<(EntryInfo, String)> {
276    let mut added_entries = HashSet::new();
277    let mut res = vec![];
278
279    for path in materialized_tree {
280        if let Some(path) = path.strip_prefix(dir) {
281            match path.split_once('/') {
282                None => {
283                    // TODO(https://fxbug.dev/42161818) Replace .contains/.insert with .get_or_insert_owned when non-experimental.
284                    if !added_entries.contains(path) {
285                        res.push((
286                            EntryInfo::new(fio::INO_UNKNOWN, fio::DirentType::File),
287                            path.to_string(),
288                        ));
289                        added_entries.insert(path.to_string());
290                    }
291                }
292                Some((first, _)) => {
293                    if !added_entries.contains(first) {
294                        res.push((
295                            EntryInfo::new(fio::INO_UNKNOWN, fio::DirentType::Directory),
296                            first.to_string(),
297                        ));
298                        added_entries.insert(first.to_string());
299                    }
300                }
301            }
302        }
303    }
304
305    // TODO(https://fxbug.dev/42162840) Remove this sort
306    res.sort_by(|a, b| a.1.cmp(&b.1));
307    res
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use assert_matches::assert_matches;
314    use fuchsia_hash::Hash;
315    use fuchsia_pkg_testing::PackageBuilder;
316    use fuchsia_pkg_testing::blobfs::Fake as FakeBlobfs;
317    use futures::StreamExt;
318    use vfs::directory::helper::DirectlyMutable;
319
320    #[fuchsia::test]
321    async fn serve() {
322        let (proxy, server_end) = fidl::endpoints::create_proxy();
323        let package = PackageBuilder::new("just-meta-far").build().await.expect("created pkg");
324        let (metafar_blob, _) = package.contents();
325        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
326        blobfs_fake.add_blob(metafar_blob.merkle, metafar_blob.contents);
327
328        crate::serve(
329            vfs::execution_scope::ExecutionScope::new(),
330            blobfs_client,
331            metafar_blob.merkle,
332            fio::PERM_READABLE,
333            server_end,
334        )
335        .await
336        .unwrap();
337
338        assert_eq!(
339            fuchsia_fs::directory::readdir(&proxy).await.unwrap(),
340            vec![fuchsia_fs::directory::DirEntry {
341                name: "meta".to_string(),
342                kind: fuchsia_fs::directory::DirentKind::Directory
343            }]
344        );
345    }
346
347    #[fuchsia::test]
348    async fn serve_path_open_root() {
349        let (proxy, server_end) = fidl::endpoints::create_proxy::<fio::DirectoryMarker>();
350        let package = PackageBuilder::new("just-meta-far").build().await.expect("created pkg");
351        let (metafar_blob, _) = package.contents();
352        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
353        blobfs_fake.add_blob(metafar_blob.merkle, metafar_blob.contents);
354
355        crate::serve_path(
356            vfs::execution_scope::ExecutionScope::new(),
357            blobfs_client,
358            metafar_blob.merkle,
359            fio::PERM_READABLE,
360            vfs::Path::validate_and_split(".").unwrap(),
361            server_end.into_channel().into(),
362        )
363        .await
364        .unwrap();
365
366        assert_eq!(
367            fuchsia_fs::directory::readdir(&proxy).await.unwrap(),
368            vec![fuchsia_fs::directory::DirEntry {
369                name: "meta".to_string(),
370                kind: fuchsia_fs::directory::DirentKind::Directory
371            }]
372        );
373    }
374
375    #[fuchsia::test]
376    async fn serve_path_open_meta() {
377        let (proxy, server_end) = fidl::endpoints::create_proxy::<fio::FileMarker>();
378        let package = PackageBuilder::new("just-meta-far").build().await.expect("created pkg");
379        let (metafar_blob, _) = package.contents();
380        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
381        blobfs_fake.add_blob(metafar_blob.merkle, metafar_blob.contents);
382
383        crate::serve_path(
384            vfs::execution_scope::ExecutionScope::new(),
385            blobfs_client,
386            metafar_blob.merkle,
387            fio::PERM_READABLE | fio::Flags::PROTOCOL_FILE,
388            vfs::Path::validate_and_split("meta").unwrap(),
389            server_end.into_channel().into(),
390        )
391        .await
392        .unwrap();
393
394        assert_eq!(
395            fuchsia_fs::file::read_to_string(&proxy).await.unwrap(),
396            metafar_blob.merkle.to_string(),
397        );
398    }
399
400    #[fuchsia::test]
401    async fn serve_path_open_missing_path_in_package() {
402        let (proxy, server_end) = fidl::endpoints::create_proxy::<fio::NodeMarker>();
403        let package = PackageBuilder::new("just-meta-far").build().await.expect("created pkg");
404        let (metafar_blob, _) = package.contents();
405        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
406        blobfs_fake.add_blob(metafar_blob.merkle, metafar_blob.contents);
407
408        assert_matches!(
409            crate::serve_path(
410                vfs::execution_scope::ExecutionScope::new(),
411                blobfs_client,
412                metafar_blob.merkle,
413                fio::PERM_READABLE | fio::Flags::FLAG_SEND_REPRESENTATION,
414                vfs::Path::validate_and_split("not-present").unwrap(),
415                server_end.into_channel().into(),
416            )
417            .await,
418            // serve_path succeeds in opening the package, but the forwarded open will discover
419            // that the requested path does not exist.
420            Ok(())
421        );
422
423        assert_eq!(node_into_on_open_status(proxy).await, Some(zx::Status::NOT_FOUND));
424    }
425
426    #[fuchsia::test]
427    async fn serve_path_open_missing_package() {
428        let (proxy, server_end) = fidl::endpoints::create_proxy::<fio::NodeMarker>();
429        let (_blobfs_fake, blobfs_client) = FakeBlobfs::new();
430
431        assert_matches!(
432            crate::serve_path(
433                vfs::execution_scope::ExecutionScope::new(),
434                blobfs_client,
435                Hash::from([0u8; 32]),
436                fio::PERM_READABLE | fio::Flags::FLAG_SEND_REPRESENTATION,
437                vfs::Path::validate_and_split(".").unwrap(),
438                server_end.into_channel().into(),
439            )
440            .await,
441            Err(Error::MissingMetaFar)
442        );
443
444        assert_eq!(node_into_on_open_status(proxy).await, Some(zx::Status::NOT_FOUND));
445    }
446
447    async fn node_into_on_open_status(node: fio::NodeProxy) -> Option<zx::Status> {
448        // Handle either an io1 OnOpen Status or an io2 epitaph status, though only one will be
449        // sent, determined by the open() API used.
450        let mut events = node.take_event_stream();
451        match events.next().await? {
452            Ok(fio::NodeEvent::OnOpen_ { s: status, .. }) => zx::Status::try_from_raw(status),
453            Ok(fio::NodeEvent::OnRepresentation { .. }) => None,
454            Err(fidl::Error::ClientChannelClosed { epitaph, .. }) => match epitaph.into() {
455                Err(s) => Some(s),
456                Ok(()) => Some(zx::Status::PEER_CLOSED),
457            },
458            other => panic!("unexpected stream event or error: {other:?}"),
459        }
460    }
461
462    fn file() -> EntryInfo {
463        EntryInfo::new(fio::INO_UNKNOWN, fio::DirentType::File)
464    }
465
466    fn dir() -> EntryInfo {
467        EntryInfo::new(fio::INO_UNKNOWN, fio::DirentType::Directory)
468    }
469
470    #[test]
471    fn get_dir_children_root() {
472        assert_eq!(get_dir_children([], ""), vec![]);
473        assert_eq!(get_dir_children(["a"], ""), vec![(file(), "a".to_string())]);
474        assert_eq!(
475            get_dir_children(["a", "b"], ""),
476            vec![(file(), "a".to_string()), (file(), "b".to_string())]
477        );
478        assert_eq!(
479            get_dir_children(["b", "a"], ""),
480            vec![(file(), "a".to_string()), (file(), "b".to_string())]
481        );
482        assert_eq!(get_dir_children(["a", "a"], ""), vec![(file(), "a".to_string())]);
483        assert_eq!(get_dir_children(["a/b"], ""), vec![(dir(), "a".to_string())]);
484        assert_eq!(
485            get_dir_children(["a/b", "c"], ""),
486            vec![(dir(), "a".to_string()), (file(), "c".to_string())]
487        );
488        assert_eq!(get_dir_children(["a/b/c"], ""), vec![(dir(), "a".to_string())]);
489    }
490
491    #[test]
492    fn get_dir_children_subdir() {
493        assert_eq!(get_dir_children([], "a/"), vec![]);
494        assert_eq!(get_dir_children(["a"], "a/"), vec![]);
495        assert_eq!(get_dir_children(["a", "b"], "a/"), vec![]);
496        assert_eq!(get_dir_children(["a/b"], "a/"), vec![(file(), "b".to_string())]);
497        assert_eq!(
498            get_dir_children(["a/b", "a/c"], "a/"),
499            vec![(file(), "b".to_string()), (file(), "c".to_string())]
500        );
501        assert_eq!(
502            get_dir_children(["a/c", "a/b"], "a/"),
503            vec![(file(), "b".to_string()), (file(), "c".to_string())]
504        );
505        assert_eq!(get_dir_children(["a/b", "a/b"], "a/"), vec![(file(), "b".to_string())]);
506        assert_eq!(get_dir_children(["a/b/c"], "a/"), vec![(dir(), "b".to_string())]);
507        assert_eq!(
508            get_dir_children(["a/b/c", "a/d"], "a/"),
509            vec![(dir(), "b".to_string()), (file(), "d".to_string())]
510        );
511        assert_eq!(get_dir_children(["a/b/c/d"], "a/"), vec![(dir(), "b".to_string())]);
512    }
513
514    const BLOB_CONTENTS: &[u8] = b"blob-contents";
515
516    fn blob_contents_hash() -> Hash {
517        fuchsia_merkle::root_from_slice(BLOB_CONTENTS)
518    }
519
520    #[fuchsia::test]
521    async fn bootfs_get_vmo_blob() {
522        let directory = vfs::directory::immutable::simple();
523        directory
524            .add_entry(blob_contents_hash().to_string(), vfs::file::read_only(BLOB_CONTENTS))
525            .unwrap();
526        let proxy = vfs::directory::serve_read_only(directory, ExecutionScope::new());
527
528        let vmo = proxy.get_blob_vmo(&blob_contents_hash()).await.unwrap();
529        assert_eq!(vmo.read_to_vec::<u8>(0, BLOB_CONTENTS.len() as u64).unwrap(), BLOB_CONTENTS);
530    }
531
532    #[fuchsia::test]
533    async fn bootfs_read_blob() {
534        let directory = vfs::directory::immutable::simple();
535        directory
536            .add_entry(blob_contents_hash().to_string(), vfs::file::read_only(BLOB_CONTENTS))
537            .unwrap();
538        let proxy = vfs::directory::serve_read_only(directory, ExecutionScope::new());
539
540        assert_eq!(proxy.read_blob(&blob_contents_hash()).await.unwrap(), BLOB_CONTENTS);
541    }
542
543    #[fuchsia::test]
544    async fn bootfs_get_vmo_blob_missing_blob() {
545        let directory = vfs::directory::immutable::simple();
546        let proxy = vfs::directory::serve_read_only(directory, ExecutionScope::new());
547
548        let result = proxy.get_blob_vmo(&blob_contents_hash()).await;
549        assert_matches!(result, Err(NonMetaStorageError::OpenBlob(e)) if e.is_not_found_error());
550    }
551
552    #[fuchsia::test]
553    async fn bootfs_read_blob_missing_blob() {
554        let directory = vfs::directory::immutable::simple();
555        let proxy = vfs::directory::serve_read_only(directory, ExecutionScope::new());
556
557        let result = proxy.read_blob(&blob_contents_hash()).await;
558        assert_matches!(result, Err(NonMetaStorageError::ReadBlob(e)) if e.is_not_found_error());
559    }
560
561    #[fuchsia::test]
562    async fn blobfs_get_vmo_blob() {
563        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
564        blobfs_fake.add_blob(blob_contents_hash(), BLOB_CONTENTS);
565
566        let vmo =
567            NonMetaStorage::get_blob_vmo(&blobfs_client, &blob_contents_hash()).await.unwrap();
568        assert_eq!(vmo.read_to_vec::<u8>(0, BLOB_CONTENTS.len() as u64).unwrap(), BLOB_CONTENTS);
569    }
570
571    #[fuchsia::test]
572    async fn blobfs_read_blob() {
573        let (blobfs_fake, blobfs_client) = FakeBlobfs::new();
574        blobfs_fake.add_blob(blob_contents_hash(), BLOB_CONTENTS);
575
576        assert_eq!(blobfs_client.read_blob(&blob_contents_hash()).await.unwrap(), BLOB_CONTENTS);
577    }
578
579    #[fuchsia::test]
580    async fn blobfs_get_vmo_blob_missing_blob() {
581        let (_blobfs_fake, blobfs_client) = FakeBlobfs::new();
582
583        let result = NonMetaStorage::get_blob_vmo(&blobfs_client, &blob_contents_hash()).await;
584        assert_matches!(result, Err(e) if e.is_not_found_error());
585    }
586
587    #[fuchsia::test]
588    async fn blobfs_read_blob_missing_blob() {
589        let (_blobfs_fake, blobfs_client) = FakeBlobfs::new();
590
591        let result = blobfs_client.read_blob(&blob_contents_hash()).await;
592        assert_matches!(result, Err(e) if e.is_not_found_error());
593    }
594}