Skip to main content

rust_ipfs/
mfs.rs

1//! A mutable filesystem (MFS) layer over immutable UnixFS DAGs.
2
3use crate::path::{IpfsPath, PathRoot};
4use crate::repo::{DataStore, DefaultStorage, Repo};
5use crate::{Block, Error};
6use anyhow::anyhow;
7use bytes::Bytes;
8use futures::future::BoxFuture;
9use futures::stream::BoxStream;
10use futures::{FutureExt, Stream, StreamExt};
11use ipld_core::cid::{Cid, Version};
12use multihash_codetable::{Code, MultihashDigest};
13use rust_unixfs::dir::builder::{BufferingTreeBuilder, TreeOptions};
14use rust_unixfs::dir::{DirLink, NodeDescription, describe};
15use rust_unixfs::file::adder::{
16    FileAdder, FileBranchLink, build_file_from_leaves, parse_file_branch, rebuild_file_branch,
17};
18use rust_unixfs::file::visit::IdleFileVisit;
19use std::collections::{BTreeMap, BTreeSet, HashSet};
20use std::future::IntoFuture;
21#[cfg(not(target_arch = "wasm32"))]
22use std::path::Path;
23use std::pin::Pin;
24use std::str::FromStr;
25use std::task::{Context, Poll};
26#[cfg(not(target_arch = "wasm32"))]
27use tokio::io::AsyncWriteExt;
28
29/// Datastore key holding the current MFS root Cid.
30const ROOT_KEY: &[u8] = b"/mfs/root";
31
32const VERSION: Version = Version::V1;
33const HASHER: Code = Code::Sha2_256;
34const RAW_LEAF_CODEC: u64 = 0x55;
35const CHUNK: u64 = 256 * 1024;
36const MAX_FILE_SIZE: u64 = 8 << 30;
37
38/// A directory's immediate children. name to target Cid, cumulative dag size used as the link Tsize.
39type DirMap = BTreeMap<String, DirEntry>;
40
41#[derive(Debug, Clone, Copy)]
42struct DirEntry {
43    cid: Cid,
44    tsize: u64,
45}
46
47/// The kind of an MFS entry.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum MfsKind {
50    File { size: u64 },
51    Directory,
52    Symlink,
53}
54
55/// A single entry in an MFS directory listing.
56#[derive(Debug, Clone)]
57pub struct MfsEntry {
58    pub name: String,
59    pub cid: Cid,
60    pub kind: MfsKind,
61    /// Logical size: file size for files, cumulative dag size for directories.
62    pub size: u64,
63}
64
65/// The result of `stat`.
66#[derive(Debug, Clone)]
67pub struct MfsStat {
68    pub cid: Cid,
69    pub kind: MfsKind,
70    /// Logical size: file size for files, cumulative dag size (link Tsize) for directories.
71    pub size: u64,
72    /// Total size of the entry's whole dag, including all descendant blocks.
73    pub cumulative_size: u64,
74    /// Number of links in the entry's root node.
75    pub blocks: usize,
76}
77
78#[derive(Debug, thiserror::Error)]
79pub enum MfsError {
80    #[error("'{0}' does not exist")]
81    NotFound(String),
82    #[error("'{0}' is a directory")]
83    IsDirectory(String),
84    #[error("'{0}' already exists")]
85    AlreadyExists(String),
86    #[error("'{0}' is a non-empty directory; pass recursive")]
87    NotEmpty(String),
88    #[error("'{0}' is a symlink")]
89    IsSymlink(String),
90    #[error("the write would exceed the MFS file size limit")]
91    FileSizeLimit,
92    #[error("the root directory cannot be modified")]
93    RootImmutable,
94}
95
96#[derive(Debug, Clone, Default)]
97pub struct WriteOptions {
98    pub offset: u64,
99    pub create: bool,
100    pub parents: bool,
101    pub truncate: bool,
102}
103
104#[derive(Debug)]
105pub enum WriteStatus {
106    Progress { written: u64, total: Option<u64> },
107    Completed { cid: Cid },
108    Failed { error: Error },
109}
110
111pub struct MfsWrite {
112    inner: BoxStream<'static, WriteStatus>,
113}
114
115impl Stream for MfsWrite {
116    type Item = WriteStatus;
117
118    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
119        self.inner.poll_next_unpin(cx)
120    }
121}
122
123impl IntoFuture for MfsWrite {
124    type Output = Result<Cid, Error>;
125    type IntoFuture = BoxFuture<'static, Result<Cid, Error>>;
126
127    fn into_future(self) -> Self::IntoFuture {
128        async move {
129            let mut stream = self.inner;
130            let mut cid = None;
131            while let Some(status) = stream.next().await {
132                match status {
133                    WriteStatus::Completed { cid: completed } => cid = Some(completed),
134                    WriteStatus::Failed { error } => return Err(error),
135                    WriteStatus::Progress { .. } => {}
136                }
137            }
138            cid.ok_or_else(|| anyhow!("streaming write produced no result"))
139        }
140        .boxed()
141    }
142}
143
144#[derive(Debug)]
145pub enum ReadStatus {
146    Progress { written: u64, total: u64 },
147    Completed { written: u64 },
148    Failed { error: Error },
149}
150
151pub struct MfsRead {
152    inner: BoxStream<'static, ReadStatus>,
153}
154
155impl Stream for MfsRead {
156    type Item = ReadStatus;
157
158    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
159        self.inner.poll_next_unpin(cx)
160    }
161}
162
163impl IntoFuture for MfsRead {
164    type Output = Result<u64, Error>;
165    type IntoFuture = BoxFuture<'static, Result<u64, Error>>;
166
167    fn into_future(self) -> Self::IntoFuture {
168        async move {
169            let mut stream = self.inner;
170            let mut written = 0;
171            while let Some(status) = stream.next().await {
172                match status {
173                    ReadStatus::Completed { written: total } => written = total,
174                    ReadStatus::Failed { error } => return Err(error),
175                    ReadStatus::Progress { .. } => {}
176                }
177            }
178            Ok(written)
179        }
180        .boxed()
181    }
182}
183
184/// Handle to the node's mutable filesystem. Obtain it via [`crate::Ipfs::mfs`].
185#[derive(Clone)]
186pub struct Mfs {
187    repo: Repo<DefaultStorage>,
188    shard_threshold: Option<u64>,
189}
190
191impl Mfs {
192    pub(crate) fn new(repo: Repo<DefaultStorage>) -> Self {
193        Self {
194            repo,
195            shard_threshold: Some(256 * 1024),
196        }
197    }
198
199    pub fn with_shard_threshold(mut self, threshold: Option<u64>) -> Self {
200        self.shard_threshold = threshold;
201        self
202    }
203
204    fn repo(&self) -> &Repo<DefaultStorage> {
205        &self.repo
206    }
207
208    /// Returns the current MFS root Cid, or `None` if nothing has been written yet.
209    pub async fn root(&self) -> Result<Option<Cid>, Error> {
210        let mut guard = self.repo().inner.mfs_root.lock().await;
211        self.cached_root(&mut guard).await
212    }
213
214    /// Creates a directory at `path`. With `parents`, missing intermediate directories are created;
215    /// otherwise a missing parent is an error.
216    pub async fn mkdir(&self, path: &str, parents: bool) -> Result<(), Error> {
217        let (cid, tsize, blocks) = encode_dir(&DirMap::new(), self.shard_threshold)?;
218        self.set_entry(path, DirEntry { cid, tsize }, blocks, parents, false)
219            .await
220    }
221
222    /// Writes `data` as the whole file at `path`, creating or replacing it.
223    pub async fn write(&self, path: &str, data: &[u8], parents: bool) -> Result<(), Error> {
224        self.write_with(
225            path,
226            data,
227            WriteOptions {
228                offset: 0,
229                create: true,
230                parents,
231                truncate: true,
232            },
233        )
234        .await
235    }
236
237    /// Writes `data` into the file at `path` starting at `opts.offset`, preserving the surrounding
238    /// bytes (read-modify-rewrite). `create` makes a missing file, `truncate` discards existing
239    /// content first, `parents` creates missing directories.
240    pub async fn write_with(
241        &self,
242        path: &str,
243        data: &[u8],
244        opts: WriteOptions,
245    ) -> Result<(), Error> {
246        let comps = split_path(path)?;
247        if comps.is_empty() {
248            return Err(MfsError::RootImmutable.into());
249        }
250
251        let new_size = opts.offset.saturating_add(data.len() as u64);
252        if new_size > MAX_FILE_SIZE {
253            return Err(MfsError::FileSizeLimit.into());
254        }
255
256        let mut guard = self.repo().inner.mfs_root.lock().await;
257
258        let existing = self.resolve_file_locked(&mut guard, &comps).await?;
259        if existing.is_none() && !opts.create {
260            return Err(MfsError::NotFound(path.to_string()).into());
261        }
262
263        if let Some((cid, tsize, filesize)) = existing
264            && !opts.truncate
265            && new_size <= filesize
266        {
267            let edited = {
268                let _gc = self.repo().gc_guard().await;
269                self.overwrite_subtree(cid, 0, data, opts.offset).await?
270            };
271            if let Some((new_cid, blocks)) = edited {
272                return self
273                    .set_entry_locked(
274                        &mut guard,
275                        path,
276                        DirEntry {
277                            cid: new_cid,
278                            tsize,
279                        },
280                        blocks,
281                        opts.parents,
282                        true,
283                    )
284                    .await;
285            }
286        }
287
288        if let Some((cid, _, filesize)) = existing
289            && !opts.truncate
290            && new_size > filesize
291            && let Some((new_cid, new_tsize, blocks)) =
292                self.grow_file(cid, filesize, opts.offset, data).await?
293        {
294            return self
295                .set_entry_locked(
296                    &mut guard,
297                    path,
298                    DirEntry {
299                        cid: new_cid,
300                        tsize: new_tsize,
301                    },
302                    blocks,
303                    opts.parents,
304                    true,
305                )
306                .await;
307        }
308
309        let mut content = if opts.truncate || existing.is_none() {
310            Vec::new()
311        } else {
312            self.read_existing_file_locked(&mut guard, &comps)
313                .await?
314                .unwrap_or_default()
315        };
316        let offset = opts.offset as usize;
317        let end = offset + data.len();
318        if content.len() < end {
319            content.resize(end, 0);
320        }
321        content[offset..end].copy_from_slice(data);
322
323        let (cid, tsize, blocks) = encode_file(&content)?;
324        self.set_entry_locked(
325            &mut guard,
326            path,
327            DirEntry { cid, tsize },
328            blocks,
329            opts.parents,
330            true,
331        )
332        .await
333    }
334
335    /// Sets the file at `path` to exactly `size` bytes, truncating or zero-extending.
336    pub async fn truncate(&self, path: &str, size: u64) -> Result<(), Error> {
337        let comps = split_path(path)?;
338        if comps.is_empty() {
339            return Err(MfsError::RootImmutable.into());
340        }
341        if size > MAX_FILE_SIZE {
342            return Err(MfsError::FileSizeLimit.into());
343        }
344
345        let mut guard = self.repo().inner.mfs_root.lock().await;
346        let (file_cid, _, filesize) = self
347            .resolve_file_locked(&mut guard, &comps)
348            .await?
349            .ok_or_else(|| MfsError::NotFound(path.to_string()))?;
350        if size == filesize {
351            return Ok(());
352        }
353
354        let boundary = (size.min(filesize) / CHUNK) * CHUNK;
355        let read_end = size.min(filesize);
356        let result = {
357            let _gc = self.repo().gc_guard().await;
358            let mut new_tail = if boundary < read_end {
359                self.read_file_range(&file_cid, boundary, read_end).await?
360            } else {
361                Vec::new()
362            };
363            new_tail.resize((size - boundary) as usize, 0);
364            self.rebuild_from_boundary(file_cid, filesize, boundary, new_tail)
365                .await?
366        };
367
368        let (cid, tsize, blocks) = match result {
369            Some(r) => r,
370            None => {
371                let mut content = self
372                    .read_existing_file_locked(&mut guard, &comps)
373                    .await?
374                    .unwrap_or_default();
375                content.resize(size as usize, 0);
376                encode_file(&content)?
377            }
378        };
379        self.set_entry_locked(
380            &mut guard,
381            path,
382            DirEntry { cid, tsize },
383            blocks,
384            false,
385            true,
386        )
387        .await
388    }
389
390    #[cfg(not(target_arch = "wasm32"))]
391    /// Write the whole content of `file` at `path`.
392    pub fn write_from_file(&self, path: &str, file: impl AsRef<Path>, parents: bool) -> MfsWrite {
393        let mfs = self.clone();
394        let path = path.to_string();
395        let file = file.as_ref().to_path_buf();
396
397        let inner = async_stream::stream! {
398            let meta = match tokio::fs::metadata(&file).await {
399                Ok(meta) => meta,
400                Err(e) => {
401                    yield WriteStatus::Failed { error: e.into() };
402                    return;
403                }
404            };
405            if !meta.is_file() {
406                yield WriteStatus::Failed {
407                    error: std::io::Error::new(
408                        std::io::ErrorKind::InvalidInput,
409                        format!("'{}' is not a valid file", file.display()),
410                    )
411                    .into(),
412                };
413                return;
414            }
415            if meta.len() > MAX_FILE_SIZE {
416                yield WriteStatus::Failed {
417                    error: std::io::Error::new(
418                        std::io::ErrorKind::FileTooLarge,
419                        format!("'{}' is too large", file.display()),
420                    )
421                    .into(),
422                };
423                return;
424            }
425            let opened = match tokio::fs::File::open(&file).await {
426                Ok(opened) => opened,
427                Err(e) => {
428                    yield WriteStatus::Failed { error: e.into() };
429                    return;
430                }
431            };
432            let reader = tokio_util::io::ReaderStream::new(opened).boxed();
433            for await status in mfs.write_progress(path, reader, parents, Some(meta.len())) {
434                yield status;
435            }
436        };
437
438        MfsWrite {
439            inner: inner.boxed(),
440        }
441    }
442
443    /// Writes the whole file at `path` from a byte stream without buffering the content, creating or
444    /// replacing it.
445    pub fn write_stream<S>(&self, path: &str, stream: S, parents: bool) -> MfsWrite
446    where
447        S: Stream<Item = std::io::Result<Bytes>> + Send + 'static,
448    {
449        let inner = self
450            .clone()
451            .write_progress(path.to_string(), stream.boxed(), parents, None);
452        MfsWrite {
453            inner: inner.boxed(),
454        }
455    }
456
457    fn write_progress(
458        self,
459        path: String,
460        mut stream: BoxStream<'static, std::io::Result<Bytes>>,
461        parents: bool,
462        total: Option<u64>,
463    ) -> impl Stream<Item = WriteStatus> {
464        async_stream::stream! {
465            let mut adder = FileAdder::builder()
466                .with_cid_version(VERSION)
467                .with_hasher(HASHER)
468                .build();
469            let mut tsize = 0u64;
470            let mut written = 0u64;
471            let mut root = None;
472
473            while let Some(chunk) = stream.next().await {
474                let chunk = match chunk {
475                    Ok(chunk) => chunk,
476                    Err(e) => {
477                        yield WriteStatus::Failed { error: e.into() };
478                        return;
479                    }
480                };
481                let mut offset = 0;
482                while offset < chunk.len() {
483                    let (ready, consumed) = adder.push(&chunk[offset..]);
484                    let mut batch = Vec::new();
485                    for (cid, block) in ready {
486                        tsize += block.len() as u64;
487                        root = Some(cid);
488                        match Block::new(cid, block) {
489                            Ok(block) => batch.push(block),
490                            Err(e) => {
491                                yield WriteStatus::Failed { error: e.into() };
492                                return;
493                            }
494                        }
495                    }
496                    if !batch.is_empty()
497                        && let Err(e) = self.repo().put_blocks(batch).await
498                    {
499                        yield WriteStatus::Failed { error: e };
500                        return;
501                    }
502                    offset += consumed;
503                    written += consumed as u64;
504                    if written > MAX_FILE_SIZE {
505                        yield WriteStatus::Failed { error: MfsError::FileSizeLimit.into() };
506                        return;
507                    }
508                }
509                yield WriteStatus::Progress { written, total };
510            }
511
512            let mut batch = Vec::new();
513            for (cid, block) in adder.finish() {
514                tsize += block.len() as u64;
515                root = Some(cid);
516                match Block::new(cid, block) {
517                    Ok(block) => batch.push(block),
518                    Err(e) => {
519                        yield WriteStatus::Failed { error: e.into() };
520                        return;
521                    }
522                }
523            }
524            if !batch.is_empty()
525                && let Err(e) = self.repo().put_blocks(batch).await
526            {
527                yield WriteStatus::Failed { error: e };
528                return;
529            }
530
531            let cid = match root {
532                Some(cid) => cid,
533                None => {
534                    yield WriteStatus::Failed { error: anyhow!("file produced no blocks") };
535                    return;
536                }
537            };
538
539            match self
540                .set_entry(&path, DirEntry { cid, tsize }, Vec::new(), parents, true)
541                .await
542            {
543                Ok(()) => yield WriteStatus::Completed { cid },
544                Err(e) => yield WriteStatus::Failed { error: e },
545            }
546        }
547    }
548
549    /// Reads the whole content of the file at `path`.
550    pub async fn read(&self, path: &str) -> Result<Vec<u8>, Error> {
551        self.read_range(path, 0, None).await
552    }
553
554    /// Reads `count` bytes (or to EOF if `None`) of the file at `path`, starting at `offset`.
555    pub async fn read_range(
556        &self,
557        path: &str,
558        offset: u64,
559        count: Option<u64>,
560    ) -> Result<Vec<u8>, Error> {
561        let stream = self.read_stream_range(path, offset, count);
562        futures::pin_mut!(stream);
563        let mut out = Vec::new();
564        while let Some(chunk) = stream.next().await {
565            out.extend_from_slice(&chunk?);
566        }
567        Ok(out)
568    }
569
570    /// Streams the content of the file at `path` as it is read, without buffering the whole file.
571    pub fn read_stream(&self, path: &str) -> impl Stream<Item = Result<Bytes, Error>> {
572        self.read_stream_range(path, 0, None)
573    }
574
575    /// Streams `count` bytes (or to EOF if `None`) of the file at `path`, starting at `offset`,
576    /// reading only the blocks that overlap the range.
577    pub fn read_stream_range(
578        &self,
579        path: &str,
580        offset: u64,
581        count: Option<u64>,
582    ) -> impl Stream<Item = Result<Bytes, Error>> {
583        let mfs = self.clone();
584        let path = path.to_string();
585        let end = count.map_or(u64::MAX, |c| offset.saturating_add(c));
586
587        async_stream::try_stream! {
588            let comps = split_path(&path)?;
589            if comps.is_empty() {
590                Err::<(), _>(anyhow!("cannot read the root directory"))?;
591            }
592            let root = mfs
593                .snapshot_root()
594                .await?
595                .ok_or_else(|| anyhow!("MFS is empty"))?;
596            let cid = {
597                let _gc = mfs.repo().gc_guard().await;
598                mfs.resolve_from(root, &comps).await?.0
599            };
600
601            let block = mfs.get_block(&cid).await?;
602            match describe(block.data()) {
603                NodeDescription::Directory { .. } | NodeDescription::HamtShard { .. } => {
604                    Err::<(), _>(MfsError::IsDirectory(path.clone()))?;
605                }
606                NodeDescription::Symlink => {
607                    Err::<(), _>(MfsError::IsSymlink(path.clone()))?;
608                }
609                NodeDescription::Other => {
610                    let leaf = block.data();
611                    let start = (offset as usize).min(leaf.len());
612                    let stop = match count {
613                        Some(c) => start.saturating_add(c as usize).min(leaf.len()),
614                        None => leaf.len(),
615                    };
616                    if start < stop {
617                        yield Bytes::copy_from_slice(&leaf[start..stop]);
618                    }
619                }
620                _ => {
621                    let mut cache = None;
622                    let (content, _, _, mut step) = IdleFileVisit::default()
623                        .with_target_range(offset..end)
624                        .start(block.data())?;
625                    if !content.is_empty() {
626                        yield Bytes::copy_from_slice(content);
627                    }
628                    while let Some(visit) = step {
629                        let next = *visit.pending_links().0;
630                        let block = mfs.get_block(&next).await?;
631                        let (content, next_step) = visit.continue_walk(block.data(), &mut cache)?;
632                        if !content.is_empty() {
633                            yield Bytes::copy_from_slice(content);
634                        }
635                        step = next_step;
636                    }
637                }
638            }
639        }
640    }
641
642    /// Reads the file at `path` and writes it to the local filesystem at `dest`, streaming through
643    /// without buffering. The returned [`MfsRead`] is both a progress [`Stream`] and a future
644    /// resolving to the number of bytes written.
645    #[cfg(not(target_arch = "wasm32"))]
646    pub fn read_to_file(&self, path: &str, dest: impl AsRef<Path>) -> MfsRead {
647        let mfs = self.clone();
648        let path = path.to_string();
649        let dest = dest.as_ref().to_path_buf();
650
651        let inner = async_stream::stream! {
652            let total = match mfs.stat(&path).await {
653                Ok(stat) => match stat.kind {
654                    MfsKind::File { size } => size,
655                    _ => {
656                        yield ReadStatus::Failed { error: anyhow!("'{path}' is not a file") };
657                        return;
658                    }
659                },
660                Err(e) => {
661                    yield ReadStatus::Failed { error: e };
662                    return;
663                }
664            };
665
666            let mut file = match tokio::fs::File::create(&dest).await {
667                Ok(file) => file,
668                Err(e) => {
669                    yield ReadStatus::Failed { error: e.into() };
670                    return;
671                }
672            };
673
674            let mut written = 0u64;
675            let stream = mfs.read_stream(&path);
676            futures::pin_mut!(stream);
677            while let Some(chunk) = stream.next().await {
678                let chunk = match chunk {
679                    Ok(chunk) => chunk,
680                    Err(e) => {
681                        yield ReadStatus::Failed { error: e };
682                        return;
683                    }
684                };
685                if let Err(e) = file.write_all(&chunk).await {
686                    yield ReadStatus::Failed { error: e.into() };
687                    return;
688                }
689                written += chunk.len() as u64;
690                yield ReadStatus::Progress { written, total };
691            }
692
693            if let Err(e) = file.flush().await {
694                yield ReadStatus::Failed { error: e.into() };
695                return;
696            }
697            yield ReadStatus::Completed { written };
698        };
699
700        MfsRead {
701            inner: inner.boxed(),
702        }
703    }
704
705    /// Removes the entry at `path`. A non-empty directory requires `recursive`.
706    pub async fn rm(&self, path: &str, recursive: bool) -> Result<(), Error> {
707        self.rm_inner(path, recursive, false).await
708    }
709
710    /// Removes the entry at `path` if present, succeeding when it is already absent.
711    pub async fn rm_force(&self, path: &str, recursive: bool) -> Result<(), Error> {
712        self.rm_inner(path, recursive, true).await
713    }
714
715    async fn rm_inner(&self, path: &str, recursive: bool, force: bool) -> Result<(), Error> {
716        let comps = split_path(path)?;
717        let Some((name, dirs)) = comps.split_last() else {
718            return Err(MfsError::RootImmutable.into());
719        };
720
721        let mut guard = self.repo().inner.mfs_root.lock().await;
722        let (mut frames, names) = match self.load_chain(&mut guard, dirs, false).await {
723            Ok(chain) => chain,
724            Err(e) if force && is_not_found(&e) => return Ok(()),
725            Err(e) => return Err(e),
726        };
727
728        let entry = match frames.last().expect("root frame").get(name).copied() {
729            Some(entry) => entry,
730            None if force => return Ok(()),
731            None => return Err(MfsError::NotFound(path.to_string()).into()),
732        };
733
734        if !recursive
735            && let Ok(map) = self.load_dir(&entry.cid).await
736            && !map.is_empty()
737        {
738            return Err(MfsError::NotEmpty(path.to_string()).into());
739        }
740
741        frames.last_mut().expect("root frame").remove(name);
742        self.reencode_and_commit(&mut guard, frames, names, Vec::new())
743            .await
744    }
745
746    /// Copies `from` to the MFS path `to`. `from` is either another MFS path or an `/ipfs` (or
747    /// `/ipld`) path, in which case the referenced DAG is fetched locally and imported.
748    pub async fn cp(&self, from: &str, to: &str, parents: bool) -> Result<(), Error> {
749        let (cid, tsize) = if is_ipfs_path(from) {
750            let path = IpfsPath::from_str(from)?;
751            let root = match path.root() {
752                PathRoot::Ipld(cid) => *cid,
753                _ => return Err(anyhow!("cp source must resolve to an /ipfs or /ipld cid")),
754            };
755            let sub: Vec<String> = path.iter().map(|s| s.to_string()).collect();
756            let source = self.resolve_ipfs(root, &sub).await?;
757            let tsize = self.fetch_and_measure(source).await?;
758            (source, tsize)
759        } else {
760            let from_comps = split_path(from)?;
761            self.resolve(&from_comps).await?
762        };
763
764        let (dest, _) = self.resolve_dest(to, from).await?;
765        self.set_entry(&dest, DirEntry { cid, tsize }, Vec::new(), parents, false)
766            .await
767    }
768
769    /// Moves the MFS entry at `from` to `to`. Non-atomic: it links the destination then unlinks the
770    /// source as two commits, so a failure between them leaves the entry at both paths (never lost).
771    pub async fn mv(&self, from: &str, to: &str, parents: bool) -> Result<(), Error> {
772        let from_comps = split_path(from)?;
773        if from_comps.is_empty() {
774            return Err(MfsError::RootImmutable.into());
775        }
776        let (cid, tsize) = self.resolve(&from_comps).await?;
777        let (dest, into_dir) = self.resolve_dest(to, from).await?;
778        let dest_comps = split_path(&dest)?;
779        if dest_comps == from_comps {
780            return Ok(());
781        }
782        if dest_comps.len() > from_comps.len() && dest_comps[..from_comps.len()] == from_comps[..] {
783            return Err(anyhow!("cannot move '{from}' into its own subdirectory"));
784        }
785        // link at the destination first (safe to fail), then unlink the source
786        self.set_entry(
787            &dest,
788            DirEntry { cid, tsize },
789            Vec::new(),
790            parents,
791            !into_dir,
792        )
793        .await?;
794        self.rm(from, true).await
795    }
796
797    /// Lists the immediate entries of the directory at `path` (`/` for the root). A file path lists
798    /// the single file itself, matching `ipfs files ls`.
799    pub async fn ls(&self, path: &str) -> Result<Vec<MfsEntry>, Error> {
800        let comps = split_path(path)?;
801        let Some(root) = self.snapshot_root().await? else {
802            if comps.is_empty() {
803                return Ok(Vec::new());
804            }
805            return Err(anyhow!("MFS is empty"));
806        };
807
808        let _gc = self.repo().gc_guard().await;
809        let (cid, tsize) = self.resolve_from(root, &comps).await?;
810        let block = self.get_block(&cid).await?;
811
812        let map = match describe(block.data()) {
813            NodeDescription::Directory { links } => links_to_map(links),
814            NodeDescription::HamtShard { links } => {
815                let mut map = DirMap::new();
816                self.collect_shard(links, &mut map).await?;
817                map
818            }
819            _ => {
820                let name = comps.last().cloned().unwrap_or_default();
821                let kind = self.classify(&cid, tsize).await?;
822                return Ok(vec![MfsEntry {
823                    name,
824                    cid,
825                    kind,
826                    size: entry_size(kind, tsize),
827                }]);
828            }
829        };
830
831        let mut out = Vec::with_capacity(map.len());
832        for (name, entry) in map {
833            let kind = self.classify(&entry.cid, entry.tsize).await?;
834            out.push(MfsEntry {
835                name,
836                cid: entry.cid,
837                kind,
838                size: entry_size(kind, entry.tsize),
839            });
840        }
841        Ok(out)
842    }
843
844    /// Returns type and size information for the entry at `path`.
845    pub async fn stat(&self, path: &str) -> Result<MfsStat, Error> {
846        let comps = split_path(path)?;
847        let Some(root) = self.snapshot_root().await? else {
848            if comps.is_empty() {
849                let (cid, tsize, _) = encode_dir(&DirMap::new(), self.shard_threshold)?;
850                return Ok(MfsStat {
851                    cid,
852                    kind: MfsKind::Directory,
853                    size: tsize,
854                    cumulative_size: tsize,
855                    blocks: 0,
856                });
857            }
858            return Err(anyhow!("MFS is empty"));
859        };
860
861        let _gc = self.repo().gc_guard().await;
862        let (cid, tsize) = self.resolve_from(root, &comps).await?;
863        let block = self.get_block(&cid).await?;
864        let (kind, blocks, link_tsize) = match describe(block.data()) {
865            NodeDescription::Directory { links } | NodeDescription::HamtShard { links } => {
866                let sum = links.iter().map(|l| l.tsize).sum();
867                (MfsKind::Directory, links.len(), sum)
868            }
869            NodeDescription::Symlink => (MfsKind::Symlink, 0, 0),
870            NodeDescription::File { size } => (
871                MfsKind::File { size },
872                parse_file_branch(block.data()).map_or(0, |b| b.links.len()),
873                0,
874            ),
875            NodeDescription::Other => (
876                MfsKind::File {
877                    size: block.data().len() as u64,
878                },
879                0,
880                0,
881            ),
882        };
883
884        // the root carries no parent link, so reconstruct its Tsize the way a parent would store
885        // it (block + summed child Tsizes), consistent with what every non-root dir reports
886        let cumulative_size = if comps.is_empty() {
887            block.data().len() as u64 + link_tsize
888        } else {
889            tsize
890        };
891        Ok(MfsStat {
892            cid,
893            kind,
894            size: entry_size(kind, cumulative_size),
895            cumulative_size,
896            blocks,
897        })
898    }
899
900    /// Returns whether anything exists at `path`.
901    pub async fn exists(&self, path: &str) -> Result<bool, Error> {
902        let comps = split_path(path)?;
903        Ok(self.resolve_kind(&comps).await?.is_some())
904    }
905
906    /// Resolves a path to its kind, or `None` if nothing is there. The root is always a directory.
907    async fn resolve_kind(&self, comps: &[String]) -> Result<Option<MfsKind>, Error> {
908        let Some(root) = self.snapshot_root().await? else {
909            return Ok(comps.is_empty().then_some(MfsKind::Directory));
910        };
911        let _gc = self.repo().gc_guard().await;
912        match self.resolve_from(root, comps).await {
913            Ok((cid, tsize)) => Ok(Some(self.classify(&cid, tsize).await?)),
914            Err(_) => Ok(None),
915        }
916    }
917
918    /// Resolves the destination path for `cp`/`mv`. When `to` ends in `/` or names an existing
919    /// directory, the source basename is appended (so `cp /a/f /dir` lands at `/dir/f`).
920    async fn resolve_dest(&self, to: &str, source: &str) -> Result<(String, bool), Error> {
921        let trailing = to.ends_with('/');
922        let mut comps = split_path(to)?;
923        let into_dir =
924            trailing || matches!(self.resolve_kind(&comps).await?, Some(MfsKind::Directory));
925        if into_dir {
926            let name = base_name(source).ok_or_else(|| anyhow!("cp/mv source has no name"))?;
927            comps.push(name.to_string());
928        }
929        Ok((format!("/{}", comps.join("/")), into_dir))
930    }
931
932    /// Loads the current root Cid, reading the datastore on first access. The cache distinguishes
933    /// "not loaded yet" (`bool == false`) from "loaded, no root" (`None`).
934    async fn cached_root(&self, guard: &mut (bool, Option<Cid>)) -> Result<Option<Cid>, Error> {
935        if !guard.0 {
936            guard.1 = match self.repo().data_store().get(ROOT_KEY).await? {
937                Some(bytes) => Some(Cid::try_from(bytes.as_slice())?),
938                None => None,
939            };
940            guard.0 = true;
941        }
942        Ok(guard.1)
943    }
944
945    /// A point-in-time snapshot of the root Cid for read operations.
946    async fn snapshot_root(&self) -> Result<Option<Cid>, Error> {
947        let mut guard = self.repo().inner.mfs_root.lock().await;
948        self.cached_root(&mut guard).await
949    }
950
951    /// Inserts or replaces the entry at `path` with `entry`, storing `entry_blocks`. With `parents`,
952    /// missing intermediate directories are created. When `overwrite` is false an existing entry is
953    /// an error.
954    async fn set_entry(
955        &self,
956        path: &str,
957        entry: DirEntry,
958        entry_blocks: Vec<Block>,
959        parents: bool,
960        overwrite: bool,
961    ) -> Result<(), Error> {
962        let mut guard = self.repo().inner.mfs_root.lock().await;
963        self.set_entry_locked(&mut guard, path, entry, entry_blocks, parents, overwrite)
964            .await
965    }
966
967    async fn set_entry_locked(
968        &self,
969        guard: &mut (bool, Option<Cid>),
970        path: &str,
971        entry: DirEntry,
972        entry_blocks: Vec<Block>,
973        parents: bool,
974        overwrite: bool,
975    ) -> Result<(), Error> {
976        let comps = split_path(path)?;
977        let Some((name, dirs)) = comps.split_last() else {
978            return Err(MfsError::RootImmutable.into());
979        };
980
981        let (mut frames, names) = self.load_chain(guard, dirs, parents).await?;
982
983        let parent = frames.last_mut().expect("root frame");
984        if !overwrite && parent.contains_key(name) {
985            return Err(MfsError::AlreadyExists(path.to_string()).into());
986        }
987        parent.insert(name.clone(), entry);
988
989        self.reencode_and_commit(guard, frames, names, entry_blocks)
990            .await
991    }
992
993    /// Loads the chain of directory maps from the root along `dirs`. `frames[0]` is the root and
994    /// `names[i]` is the component linking `frames[i]` to `frames[i+1]`. With `create`, missing
995    /// intermediate directories become empty maps.
996    async fn load_chain(
997        &self,
998        guard: &mut (bool, Option<Cid>),
999        dirs: &[String],
1000        create: bool,
1001    ) -> Result<(Vec<DirMap>, Vec<String>), Error> {
1002        let root_map = match self.cached_root(guard).await? {
1003            Some(cid) => self.load_dir(&cid).await?,
1004            None => DirMap::new(),
1005        };
1006
1007        let mut frames = vec![root_map];
1008        let mut names = Vec::with_capacity(dirs.len());
1009
1010        for comp in dirs {
1011            let next = match frames.last().expect("non-empty").get(comp).copied() {
1012                Some(entry) => self.load_dir(&entry.cid).await?,
1013                None if create => DirMap::new(),
1014                None => return Err(MfsError::NotFound(comp.clone()).into()),
1015            };
1016            names.push(comp.clone());
1017            frames.push(next);
1018        }
1019
1020        Ok((frames, names))
1021    }
1022
1023    /// Re-encodes every frame bottom-up (each parent's link updated to the child's new Cid), stores
1024    /// all produced blocks, and commits the new root.
1025    async fn reencode_and_commit(
1026        &self,
1027        guard: &mut (bool, Option<Cid>),
1028        mut frames: Vec<DirMap>,
1029        names: Vec<String>,
1030        mut blocks: Vec<Block>,
1031    ) -> Result<(), Error> {
1032        for i in (0..frames.len()).rev() {
1033            let (cid, tsize, mut blks) = encode_dir(&frames[i], self.shard_threshold)?;
1034            blocks.append(&mut blks);
1035            if i == 0 {
1036                return self.commit_root(guard, cid, blocks).await;
1037            }
1038            frames[i - 1].insert(names[i - 1].clone(), DirEntry { cid, tsize });
1039        }
1040        unreachable!("frames always contains the root at index 0")
1041    }
1042
1043    /// Persists `new_root`: stores its blocks, recursively pins the new tree, unpins the old root,
1044    /// writes the root Cid to the datastore, and updates the cache.
1045    async fn commit_root(
1046        &self,
1047        guard: &mut (bool, Option<Cid>),
1048        new_root: Cid,
1049        blocks: Vec<Block>,
1050    ) -> Result<(), Error> {
1051        let old = guard.1;
1052
1053        self.repo().put_blocks(blocks).await?;
1054        self.repo().pin(new_root).recursive().await?;
1055        self.repo()
1056            .data_store()
1057            .put(ROOT_KEY, &new_root.to_bytes())
1058            .await?;
1059
1060        guard.1 = Some(new_root);
1061        guard.0 = true;
1062
1063        if let Some(old) = old
1064            && old != new_root
1065        {
1066            // best-effort: the new tree is already pinned, so a failed unpin only delays GC
1067            let _ = self.repo().remove_pin(old).recursive().await;
1068        }
1069        Ok(())
1070    }
1071
1072    /// Resolves a path (component list, empty == root) to its `(cid, link tsize)`, holding off GC
1073    /// for the duration of the navigation.
1074    async fn resolve(&self, comps: &[String]) -> Result<(Cid, u64), Error> {
1075        let root = self
1076            .snapshot_root()
1077            .await?
1078            .ok_or_else(|| anyhow!("MFS is empty"))?;
1079        let _gc = self.repo().gc_guard().await;
1080        self.resolve_from(root, comps).await
1081    }
1082
1083    /// Navigates from `root` along `comps`. The caller must hold a GC guard, since this reads
1084    /// immutable blocks with `get_block_now`.
1085    async fn resolve_from(&self, root: Cid, comps: &[String]) -> Result<(Cid, u64), Error> {
1086        let mut cid = root;
1087        let mut tsize = 0;
1088        for comp in comps {
1089            let map = self.load_dir(&cid).await?;
1090            let entry = map
1091                .get(comp)
1092                .ok_or_else(|| anyhow!("path not found: {comp}"))?;
1093            cid = entry.cid;
1094            tsize = entry.tsize;
1095        }
1096        Ok((cid, tsize))
1097    }
1098
1099    /// Loads a directory's children, flattening a HAMT shard if present. Errors on a non-directory.
1100    async fn load_dir(&self, cid: &Cid) -> Result<DirMap, Error> {
1101        let block = self.get_block(cid).await?;
1102        match describe(block.data()) {
1103            NodeDescription::Directory { links } => Ok(links_to_map(links)),
1104            NodeDescription::HamtShard { links } => {
1105                let mut map = DirMap::new();
1106                self.collect_shard(links, &mut map).await?;
1107                Ok(map)
1108            }
1109            _ => Err(anyhow!("{cid} is not a directory")),
1110        }
1111    }
1112
1113    async fn resolve_ipfs(&self, root: Cid, sub: &[String]) -> Result<Cid, Error> {
1114        let mut cid = root;
1115        for seg in sub {
1116            cid = self
1117                .resolve_name(cid, seg)
1118                .await?
1119                .ok_or_else(|| anyhow!("path not found in source: {seg}"))?;
1120        }
1121        Ok(cid)
1122    }
1123
1124    async fn resolve_name(&self, dir_cid: Cid, name: &str) -> Result<Option<Cid>, Error> {
1125        use rust_unixfs::dir::{MaybeResolved, resolve};
1126
1127        let block = self.repo().get_block(dir_cid).await?;
1128        let mut cache = None;
1129        let mut step = resolve(block.data(), name, &mut cache)?;
1130        loop {
1131            match step {
1132                MaybeResolved::Found(cid) => return Ok(Some(cid)),
1133                MaybeResolved::NotFound => return Ok(None),
1134                MaybeResolved::NeedToLoadMore(lookup) => {
1135                    let next = *lookup.pending_links().0;
1136                    let block = self.repo().get_block(next).await?;
1137                    step = lookup.continue_walk(block.data(), &mut cache)?;
1138                }
1139            }
1140        }
1141    }
1142
1143    async fn fetch_and_measure(&self, root: Cid) -> Result<u64, Error> {
1144        let mut total = 0u64;
1145        let mut seen = HashSet::new();
1146        let mut stack = vec![root];
1147        while let Some(cid) = stack.pop() {
1148            if !seen.insert(cid) {
1149                continue;
1150            }
1151            let block = self.repo().get_block(cid).await?;
1152            total += block.data().len() as u64;
1153            let mut refs = BTreeSet::new();
1154            let _ = block.references(&mut refs);
1155            stack.extend(refs);
1156        }
1157        Ok(total)
1158    }
1159
1160    fn collect_shard<'a>(
1161        &'a self,
1162        links: Vec<DirLink>,
1163        map: &'a mut DirMap,
1164    ) -> BoxFuture<'a, Result<(), Error>> {
1165        async move {
1166            for link in links {
1167                if is_shard_prefix(&link.name) {
1168                    let block = self.get_block(&link.target).await?;
1169                    match describe(block.data()) {
1170                        NodeDescription::HamtShard { links } => {
1171                            self.collect_shard(links, map).await?;
1172                        }
1173                        _ => return Err(anyhow!("malformed HAMT shard under {}", link.target)),
1174                    }
1175                } else {
1176                    let name = link.name.get(2..).unwrap_or_default().to_string();
1177                    map.insert(
1178                        name,
1179                        DirEntry {
1180                            cid: link.target,
1181                            tsize: link.tsize,
1182                        },
1183                    );
1184                }
1185            }
1186            Ok(())
1187        }
1188        .boxed()
1189    }
1190
1191    async fn classify(&self, cid: &Cid, _link_tsize: u64) -> Result<MfsKind, Error> {
1192        let block = self.get_block(cid).await?;
1193        Ok(match describe(block.data()) {
1194            NodeDescription::Directory { .. } | NodeDescription::HamtShard { .. } => {
1195                MfsKind::Directory
1196            }
1197            NodeDescription::File { size } => MfsKind::File { size },
1198            NodeDescription::Symlink => MfsKind::Symlink,
1199            NodeDescription::Other => MfsKind::File {
1200                size: block.data().len() as u64,
1201            },
1202        })
1203    }
1204
1205    async fn resolve_file_locked(
1206        &self,
1207        guard: &mut (bool, Option<Cid>),
1208        comps: &[String],
1209    ) -> Result<Option<(Cid, u64, u64)>, Error> {
1210        let Some(root) = self.cached_root(guard).await? else {
1211            return Ok(None);
1212        };
1213        let _gc = self.repo().gc_guard().await;
1214        let (cid, tsize) = match self.resolve_from(root, comps).await {
1215            Ok(resolved) => resolved,
1216            Err(_) => return Ok(None),
1217        };
1218        match self.classify(&cid, tsize).await? {
1219            MfsKind::File { size } => Ok(Some((cid, tsize, size))),
1220            MfsKind::Symlink => Err(MfsError::IsSymlink(format!("/{}", comps.join("/"))).into()),
1221            MfsKind::Directory => {
1222                Err(MfsError::IsDirectory(format!("/{}", comps.join("/"))).into())
1223            }
1224        }
1225    }
1226
1227    #[allow(clippy::type_complexity)]
1228    fn overwrite_subtree<'a>(
1229        &'a self,
1230        cid: Cid,
1231        node_start: u64,
1232        data: &'a [u8],
1233        data_start: u64,
1234    ) -> BoxFuture<'a, Result<Option<(Cid, Vec<Block>)>, Error>> {
1235        async move {
1236            let block = self.get_block(&cid).await?;
1237
1238            if let Some(branch) = parse_file_branch(block.data()) {
1239                let mut blocks = Vec::new();
1240                let mut links: Vec<FileBranchLink> = branch.links.clone();
1241                let write_end = data_start + data.len() as u64;
1242                let mut child_start = node_start;
1243                for (i, link) in branch.links.iter().enumerate() {
1244                    let child_end = child_start + link.blocksize;
1245                    if child_end > data_start && child_start < write_end {
1246                        match self
1247                            .overwrite_subtree(link.cid, child_start, data, data_start)
1248                            .await?
1249                        {
1250                            Some((new_cid, mut child_blocks)) => {
1251                                links[i].cid = new_cid;
1252                                blocks.append(&mut child_blocks);
1253                            }
1254                            None => return Ok(None),
1255                        }
1256                    }
1257                    child_start = child_end;
1258                }
1259                let (new_cid, bytes) =
1260                    rebuild_file_branch(branch.filesize, &links, VERSION, HASHER);
1261                blocks.push(Block::new(new_cid, bytes)?);
1262                return Ok(Some((new_cid, blocks)));
1263            }
1264
1265            if !matches!(describe(block.data()), NodeDescription::Other) {
1266                return Ok(None);
1267            }
1268
1269            let leaf = block.data();
1270            let ov_start = node_start.max(data_start);
1271            let ov_end = (node_start + leaf.len() as u64).min(data_start + data.len() as u64);
1272            if ov_start >= ov_end {
1273                return Ok(Some((cid, Vec::new())));
1274            }
1275            let mut new_leaf = leaf.to_vec();
1276            let dst = (ov_start - node_start) as usize..(ov_end - node_start) as usize;
1277            let src = (ov_start - data_start) as usize..(ov_end - data_start) as usize;
1278            new_leaf[dst].copy_from_slice(&data[src]);
1279            let new_cid = Cid::new_v1(RAW_LEAF_CODEC, HASHER.digest(&new_leaf));
1280            Ok(Some((new_cid, vec![Block::new(new_cid, new_leaf)?])))
1281        }
1282        .boxed()
1283    }
1284
1285    async fn read_file_range(&self, cid: &Cid, start: u64, end: u64) -> Result<Vec<u8>, Error> {
1286        let block = self.get_block(cid).await?;
1287        if matches!(describe(block.data()), NodeDescription::Other) {
1288            let leaf = block.data();
1289            let s = (start as usize).min(leaf.len());
1290            let e = (end as usize).min(leaf.len());
1291            return Ok(leaf[s..e].to_vec());
1292        }
1293
1294        let mut out = Vec::new();
1295        let mut cache = None;
1296        let (content, _, _, mut step) = IdleFileVisit::default()
1297            .with_target_range(start..end)
1298            .start(block.data())?;
1299        out.extend_from_slice(content);
1300        while let Some(visit) = step {
1301            let next = *visit.pending_links().0;
1302            let block = self.get_block(&next).await?;
1303            let (content, next_step) = visit.continue_walk(block.data(), &mut cache)?;
1304            out.extend_from_slice(content);
1305            step = next_step;
1306        }
1307        Ok(out)
1308    }
1309
1310    fn collect_prefix_leaves<'a>(
1311        &'a self,
1312        cid: Cid,
1313        node_start: u64,
1314        boundary: u64,
1315        out: &'a mut Vec<FileBranchLink>,
1316    ) -> BoxFuture<'a, Result<bool, Error>> {
1317        async move {
1318            let block = self.get_block(&cid).await?;
1319            let Some(branch) = parse_file_branch(block.data()) else {
1320                return Ok(false);
1321            };
1322            let mut child_start = node_start;
1323            for link in &branch.links {
1324                if child_start >= boundary {
1325                    break;
1326                }
1327                let child_end = child_start + link.blocksize;
1328                if link.cid.codec() == RAW_LEAF_CODEC {
1329                    if child_end <= boundary && link.blocksize == CHUNK {
1330                        out.push(link.clone());
1331                    } else {
1332                        return Ok(false);
1333                    }
1334                } else if !self
1335                    .collect_prefix_leaves(link.cid, child_start, boundary, out)
1336                    .await?
1337                {
1338                    return Ok(false);
1339                }
1340                child_start = child_end;
1341            }
1342            Ok(true)
1343        }
1344        .boxed()
1345    }
1346
1347    async fn prefix_leaves(
1348        &self,
1349        file_cid: Cid,
1350        filesize: u64,
1351        boundary: u64,
1352    ) -> Result<Option<Vec<FileBranchLink>>, Error> {
1353        if file_cid.codec() == RAW_LEAF_CODEC {
1354            let leaf = FileBranchLink {
1355                cid: file_cid,
1356                blocksize: filesize,
1357                tsize: filesize,
1358            };
1359            return Ok(Some(if filesize > 0 && filesize <= boundary {
1360                vec![leaf]
1361            } else {
1362                vec![]
1363            }));
1364        }
1365        let mut out = Vec::new();
1366        if self
1367            .collect_prefix_leaves(file_cid, 0, boundary, &mut out)
1368            .await?
1369        {
1370            Ok(Some(out))
1371        } else {
1372            Ok(None)
1373        }
1374    }
1375
1376    async fn rebuild_from_boundary(
1377        &self,
1378        file_cid: Cid,
1379        filesize: u64,
1380        boundary: u64,
1381        new_tail: Vec<u8>,
1382    ) -> Result<Option<(Cid, u64, Vec<Block>)>, Error> {
1383        let Some(mut leaves) = self.prefix_leaves(file_cid, filesize, boundary).await? else {
1384            return Ok(None);
1385        };
1386
1387        let mut new_blocks = Vec::new();
1388        for chunk in new_tail.chunks(CHUNK as usize) {
1389            let cid = Cid::new_v1(RAW_LEAF_CODEC, HASHER.digest(chunk));
1390            leaves.push(FileBranchLink {
1391                cid,
1392                blocksize: chunk.len() as u64,
1393                tsize: chunk.len() as u64,
1394            });
1395            new_blocks.push(Block::new(cid, chunk.to_vec())?);
1396        }
1397
1398        if leaves.is_empty() {
1399            let (cid, tsize, blocks) = encode_file(&[])?;
1400            return Ok(Some((cid, tsize, blocks)));
1401        }
1402
1403        let (root_cid, root_tsize, branch_blocks) =
1404            build_file_from_leaves(&leaves, VERSION, HASHER);
1405        for (cid, bytes) in branch_blocks {
1406            new_blocks.push(Block::new(cid, bytes)?);
1407        }
1408        Ok(Some((root_cid, root_tsize, new_blocks)))
1409    }
1410
1411    async fn grow_file(
1412        &self,
1413        file_cid: Cid,
1414        filesize: u64,
1415        offset: u64,
1416        data: &[u8],
1417    ) -> Result<Option<(Cid, u64, Vec<Block>)>, Error> {
1418        let boundary = (offset.min(filesize) / CHUNK) * CHUNK;
1419        let _gc = self.repo().gc_guard().await;
1420        let mut new_tail = if boundary < filesize {
1421            self.read_file_range(&file_cid, boundary, filesize).await?
1422        } else {
1423            Vec::new()
1424        };
1425        let rel_off = (offset - boundary) as usize;
1426        let rel_end = rel_off + data.len();
1427        if new_tail.len() < rel_end {
1428            new_tail.resize(rel_end, 0);
1429        }
1430        new_tail[rel_off..rel_end].copy_from_slice(data);
1431        self.rebuild_from_boundary(file_cid, filesize, boundary, new_tail)
1432            .await
1433    }
1434
1435    async fn read_existing_file_locked(
1436        &self,
1437        guard: &mut (bool, Option<Cid>),
1438        comps: &[String],
1439    ) -> Result<Option<Vec<u8>>, Error> {
1440        let Some(root) = self.cached_root(guard).await? else {
1441            return Ok(None);
1442        };
1443        let _gc = self.repo().gc_guard().await;
1444        let (cid, tsize) = match self.resolve_from(root, comps).await {
1445            Ok(resolved) => resolved,
1446            Err(_) => return Ok(None),
1447        };
1448        match self.classify(&cid, tsize).await? {
1449            MfsKind::Directory => {
1450                return Err(MfsError::IsDirectory(format!("/{}", comps.join("/"))).into());
1451            }
1452            MfsKind::Symlink => {
1453                return Err(MfsError::IsSymlink(format!("/{}", comps.join("/"))).into());
1454            }
1455            MfsKind::File { .. } => {}
1456        }
1457        Ok(Some(self.read_file(&cid).await?))
1458    }
1459
1460    /// Reads a UnixFS file DAG (or raw leaf) rooted at `cid` into a byte vector, fetching blocks
1461    /// locally (the MFS tree is pinned and present).
1462    async fn read_file(&self, cid: &Cid) -> Result<Vec<u8>, Error> {
1463        let block = self.get_block(cid).await?;
1464
1465        // a single raw leaf is the content itself
1466        if matches!(describe(block.data()), NodeDescription::Other) {
1467            return Ok(block.data().to_vec());
1468        }
1469
1470        let mut out = Vec::new();
1471        let mut cache = None;
1472        let (content, _, _, mut step) = IdleFileVisit::default().start(block.data())?;
1473        out.extend_from_slice(content);
1474
1475        while let Some(visit) = step {
1476            let next = *visit.pending_links().0;
1477            let block = self.get_block(&next).await?;
1478            let (content, next_step) = visit.continue_walk(block.data(), &mut cache)?;
1479            out.extend_from_slice(content);
1480            step = next_step;
1481        }
1482
1483        Ok(out)
1484    }
1485
1486    async fn get_block(&self, cid: &Cid) -> Result<Block, Error> {
1487        self.repo()
1488            .get_block_now(cid)
1489            .await?
1490            .ok_or_else(|| anyhow!("missing block {cid}"))
1491    }
1492}
1493
1494/// Splits an MFS path into its components, rejecting `.`/`..` and treating `/` as the root (empty).
1495fn split_path(path: &str) -> Result<Vec<String>, Error> {
1496    let mut comps = Vec::new();
1497    for segment in path.split('/') {
1498        match segment {
1499            "" => continue,
1500            "." | ".." => return Err(anyhow!("'.' and '..' are not supported in MFS paths")),
1501            other => comps.push(other.to_string()),
1502        }
1503    }
1504    Ok(comps)
1505}
1506
1507/// The logical size reported for a listing/stat entry: a file's own size, else the cumulative size.
1508fn entry_size(kind: MfsKind, cumulative: u64) -> u64 {
1509    match kind {
1510        MfsKind::File { size } => size,
1511        MfsKind::Directory | MfsKind::Symlink => cumulative,
1512    }
1513}
1514
1515/// The last non-empty path segment, mirroring `path.Base` (works for MFS and `/ipfs` paths).
1516fn base_name(path: &str) -> Option<&str> {
1517    path.rsplit('/').find(|s| !s.is_empty())
1518}
1519
1520fn is_not_found(err: &Error) -> bool {
1521    matches!(err.downcast_ref::<MfsError>(), Some(MfsError::NotFound(_)))
1522}
1523
1524fn is_shard_prefix(name: &str) -> bool {
1525    name.len() == 2 && name.bytes().all(|b| b.is_ascii_hexdigit())
1526}
1527
1528fn is_ipfs_path(path: &str) -> bool {
1529    path.starts_with("/ipfs/") || path.starts_with("/ipld/")
1530}
1531
1532fn links_to_map(links: Vec<DirLink>) -> DirMap {
1533    links
1534        .into_iter()
1535        .map(|l| {
1536            (
1537                l.name,
1538                DirEntry {
1539                    cid: l.target,
1540                    tsize: l.tsize,
1541                },
1542            )
1543        })
1544        .collect()
1545}
1546
1547fn encode_dir(
1548    entries: &DirMap,
1549    shard_threshold: Option<u64>,
1550) -> Result<(Cid, u64, Vec<Block>), Error> {
1551    let mut opts = TreeOptions::default();
1552    opts.wrap_with_directory();
1553    opts.cid_version(VERSION);
1554    opts.hasher(HASHER);
1555    opts.shard_threshold(shard_threshold);
1556
1557    let mut builder = BufferingTreeBuilder::new(opts);
1558    for (name, entry) in entries {
1559        builder.put_link(name, entry.cid, entry.tsize)?;
1560    }
1561
1562    let mut blocks = Vec::new();
1563    let mut root = None;
1564    for node in builder.build() {
1565        let node = node?;
1566        root = Some((node.cid, node.total_size));
1567        blocks.push(Block::new(node.cid, node.block.into_vec())?);
1568    }
1569
1570    let (cid, tsize) = root.ok_or_else(|| anyhow!("directory produced no node"))?;
1571    Ok((cid, tsize, blocks))
1572}
1573
1574/// Encodes `data` as a UnixFS file DAG, returning its root Cid, cumulative dag size (the link
1575/// Tsize), and every produced block (root last).
1576fn encode_file(data: &[u8]) -> Result<(Cid, u64, Vec<Block>), Error> {
1577    let mut adder = FileAdder::builder()
1578        .with_cid_version(VERSION)
1579        .with_hasher(HASHER)
1580        .build();
1581
1582    let mut blocks = Vec::new();
1583    let mut tsize = 0u64;
1584    let mut root = None;
1585
1586    let mut push = |cid: Cid, block: Vec<u8>| -> Result<(), Error> {
1587        tsize += block.len() as u64;
1588        root = Some(cid);
1589        blocks.push(Block::new(cid, block)?);
1590        Ok(())
1591    };
1592
1593    let mut offset = 0;
1594    while offset < data.len() {
1595        let (ready, consumed) = adder.push(&data[offset..]);
1596        for (cid, block) in ready {
1597            push(cid, block)?;
1598        }
1599        offset += consumed;
1600    }
1601    for (cid, block) in adder.finish() {
1602        push(cid, block)?;
1603    }
1604
1605    let cid = root.ok_or_else(|| anyhow!("file produced no blocks"))?;
1606    Ok((cid, tsize, blocks))
1607}
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612
1613    async fn mfs() -> Mfs {
1614        let repo = Repo::new_memory();
1615        repo.init().await.unwrap();
1616        Mfs::new(repo)
1617    }
1618
1619    #[tokio::test]
1620    async fn mkdir_ls_stat() {
1621        let mfs = mfs().await;
1622
1623        mfs.mkdir("/a/b/c", true).await.unwrap();
1624
1625        let top = mfs.ls("/").await.unwrap();
1626        assert_eq!(top.len(), 1);
1627        assert_eq!(top[0].name, "a");
1628        assert_eq!(top[0].kind, MfsKind::Directory);
1629
1630        let inner = mfs.ls("/a/b").await.unwrap();
1631        assert_eq!(inner.len(), 1);
1632        assert_eq!(inner[0].name, "c");
1633
1634        let st = mfs.stat("/a/b/c").await.unwrap();
1635        assert_eq!(st.kind, MfsKind::Directory);
1636    }
1637
1638    #[tokio::test]
1639    async fn empty_root_lists_empty() {
1640        let mfs = mfs().await;
1641        assert!(mfs.ls("/").await.unwrap().is_empty());
1642        assert_eq!(mfs.stat("/").await.unwrap().kind, MfsKind::Directory);
1643        assert!(
1644            mfs.root().await.unwrap().is_none(),
1645            "reads must not create a root"
1646        );
1647    }
1648
1649    #[tokio::test]
1650    async fn write_read_roundtrip() {
1651        let mfs = mfs().await;
1652
1653        mfs.write("/docs/hello.txt", b"hello mfs", true)
1654            .await
1655            .unwrap();
1656        assert_eq!(mfs.read("/docs/hello.txt").await.unwrap(), b"hello mfs");
1657
1658        let st = mfs.stat("/docs/hello.txt").await.unwrap();
1659        assert_eq!(st.kind, MfsKind::File { size: 9 });
1660
1661        // overwrite
1662        mfs.write("/docs/hello.txt", b"changed", false)
1663            .await
1664            .unwrap();
1665        assert_eq!(mfs.read("/docs/hello.txt").await.unwrap(), b"changed");
1666
1667        // a directory listing reflects the file
1668        let entries = mfs.ls("/docs").await.unwrap();
1669        assert_eq!(entries.len(), 1);
1670        assert_eq!(entries[0].name, "hello.txt");
1671        assert!(matches!(entries[0].kind, MfsKind::File { .. }));
1672    }
1673
1674    #[tokio::test]
1675    async fn write_at_offset_and_truncate() {
1676        let mfs = mfs().await;
1677        mfs.write("/f", b"hello world", true).await.unwrap();
1678
1679        mfs.write_with(
1680            "/f",
1681            b"MFS",
1682            WriteOptions {
1683                offset: 6,
1684                ..Default::default()
1685            },
1686        )
1687        .await
1688        .unwrap();
1689        assert_eq!(mfs.read("/f").await.unwrap(), b"hello MFSld");
1690
1691        mfs.write_with(
1692            "/f",
1693            b"!!",
1694            WriteOptions {
1695                offset: 13,
1696                ..Default::default()
1697            },
1698        )
1699        .await
1700        .unwrap();
1701        assert_eq!(mfs.read("/f").await.unwrap(), b"hello MFSld\0\0!!");
1702
1703        mfs.write_with(
1704            "/f",
1705            b"fresh",
1706            WriteOptions {
1707                truncate: true,
1708                ..Default::default()
1709            },
1710        )
1711        .await
1712        .unwrap();
1713        assert_eq!(mfs.read("/f").await.unwrap(), b"fresh");
1714
1715        mfs.truncate("/f", 3).await.unwrap();
1716        assert_eq!(mfs.read("/f").await.unwrap(), b"fre");
1717        mfs.truncate("/f", 5).await.unwrap();
1718        assert_eq!(mfs.read("/f").await.unwrap(), b"fre\0\0");
1719
1720        assert!(
1721            mfs.write_with("/missing", b"x", WriteOptions::default())
1722                .await
1723                .is_err()
1724        );
1725        mfs.write_with(
1726            "/created",
1727            b"y",
1728            WriteOptions {
1729                create: true,
1730                parents: true,
1731                ..Default::default()
1732            },
1733        )
1734        .await
1735        .unwrap();
1736        assert_eq!(mfs.read("/created").await.unwrap(), b"y");
1737    }
1738
1739    #[tokio::test]
1740    async fn offset_edit_multiblock() {
1741        let mfs = mfs().await;
1742        let mut data: Vec<u8> = (0..1_000_000u32).map(|i| i as u8).collect();
1743        mfs.write("/big", &data, false).await.unwrap();
1744
1745        let patch = b"PATCHED";
1746        let off = 500_000usize;
1747        mfs.write_with(
1748            "/big",
1749            patch,
1750            WriteOptions {
1751                offset: off as u64,
1752                ..Default::default()
1753            },
1754        )
1755        .await
1756        .unwrap();
1757
1758        data[off..off + patch.len()].copy_from_slice(patch);
1759        assert_eq!(mfs.read("/big").await.unwrap(), data);
1760    }
1761
1762    #[tokio::test]
1763    async fn overwrite_in_place_is_canonical_and_reuses_blocks() {
1764        use futures::StreamExt as _;
1765
1766        let repo = Repo::new_memory();
1767        repo.init().await.unwrap();
1768        let mfs = Mfs::new(repo.clone());
1769
1770        let mut content = vec![0u8; 4_000_000];
1771        let mut x = 0x1234_5678u32;
1772        for b in content.iter_mut() {
1773            x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1774            *b = (x >> 24) as u8;
1775        }
1776        mfs.write("/big", &content, false).await.unwrap();
1777
1778        let before = repo.list_blocks().await.collect::<Vec<_>>().await.len();
1779        assert!(before > 8, "file should be many blocks, got {before}");
1780
1781        let patch = b"PATCHED-IN-PLACE";
1782        let off = 1_500_000u64;
1783        mfs.write_with(
1784            "/big",
1785            patch,
1786            WriteOptions {
1787                offset: off,
1788                ..Default::default()
1789            },
1790        )
1791        .await
1792        .unwrap();
1793
1794        content[off as usize..off as usize + patch.len()].copy_from_slice(patch);
1795        let (expected_cid, _, _) = encode_file(&content).unwrap();
1796        assert_eq!(
1797            mfs.stat("/big").await.unwrap().cid,
1798            expected_cid,
1799            "editor must produce the canonical tree"
1800        );
1801        assert_eq!(mfs.read("/big").await.unwrap(), content);
1802
1803        let after = repo.list_blocks().await.collect::<Vec<_>>().await.len();
1804        assert!(
1805            after - before < 8,
1806            "expected few new blocks (reuse), added {}",
1807            after - before
1808        );
1809
1810        let patch2 = b"CROSS-CHUNK-BOUNDARY";
1811        let off2 = 262_144usize - 5;
1812        mfs.write_with(
1813            "/big",
1814            patch2,
1815            WriteOptions {
1816                offset: off2 as u64,
1817                ..Default::default()
1818            },
1819        )
1820        .await
1821        .unwrap();
1822        content[off2..off2 + patch2.len()].copy_from_slice(patch2);
1823        let (expected2, _, _) = encode_file(&content).unwrap();
1824        assert_eq!(mfs.stat("/big").await.unwrap().cid, expected2);
1825        assert_eq!(mfs.read("/big").await.unwrap(), content);
1826    }
1827
1828    fn fill(n: usize, seed: u32) -> Vec<u8> {
1829        let mut v = vec![0u8; n];
1830        let mut x = seed;
1831        for b in v.iter_mut() {
1832            x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1833            *b = (x >> 24) as u8;
1834        }
1835        v
1836    }
1837
1838    async fn assert_canonical(mfs: &Mfs, path: &str, expected: &[u8]) {
1839        let (cid, _, _) = encode_file(expected).unwrap();
1840        assert_eq!(
1841            mfs.stat(path).await.unwrap().cid,
1842            cid,
1843            "non-canonical tree for {path}"
1844        );
1845        assert_eq!(mfs.read(path).await.unwrap(), expected);
1846    }
1847
1848    #[tokio::test]
1849    async fn grow_append_truncate_are_canonical() {
1850        use futures::StreamExt as _;
1851        let repo = Repo::new_memory();
1852        repo.init().await.unwrap();
1853        let mfs = Mfs::new(repo.clone());
1854
1855        let mut content = fill(800_000, 1);
1856        mfs.write("/f", &content, true).await.unwrap();
1857        let before = repo.list_blocks().await.collect::<Vec<_>>().await.len();
1858
1859        let app = fill(300_000, 2);
1860        mfs.write_with(
1861            "/f",
1862            &app,
1863            WriteOptions {
1864                offset: content.len() as u64,
1865                ..Default::default()
1866            },
1867        )
1868        .await
1869        .unwrap();
1870        content.extend_from_slice(&app);
1871        assert_canonical(&mfs, "/f", &content).await;
1872        let after = repo.list_blocks().await.collect::<Vec<_>>().await.len();
1873        assert!(
1874            after - before < 8,
1875            "append should reuse, added {}",
1876            after - before
1877        );
1878
1879        let patch = fill(200_000, 3);
1880        let off = content.len() - 50_000;
1881        mfs.write_with(
1882            "/f",
1883            &patch,
1884            WriteOptions {
1885                offset: off as u64,
1886                ..Default::default()
1887            },
1888        )
1889        .await
1890        .unwrap();
1891        content.resize((off + patch.len()).max(content.len()), 0);
1892        content[off..off + patch.len()].copy_from_slice(&patch);
1893        assert_canonical(&mfs, "/f", &content).await;
1894
1895        mfs.truncate("/f", 600_000).await.unwrap();
1896        content.truncate(600_000);
1897        assert_canonical(&mfs, "/f", &content).await;
1898
1899        mfs.truncate("/f", 900_000).await.unwrap();
1900        content.resize(900_000, 0);
1901        assert_canonical(&mfs, "/f", &content).await;
1902
1903        mfs.truncate("/f", 0).await.unwrap();
1904        assert_canonical(&mfs, "/f", &[]).await;
1905    }
1906
1907    #[tokio::test]
1908    async fn small_file_grows_to_multiblock() {
1909        let mfs = mfs().await;
1910        mfs.write("/s", b"small", true).await.unwrap();
1911
1912        let app = fill(600_000, 9);
1913        mfs.write_with(
1914            "/s",
1915            &app,
1916            WriteOptions {
1917                offset: 5,
1918                ..Default::default()
1919            },
1920        )
1921        .await
1922        .unwrap();
1923
1924        let mut content = b"small".to_vec();
1925        content.resize(5 + app.len(), 0);
1926        content[5..5 + app.len()].copy_from_slice(&app);
1927        assert_canonical(&mfs, "/s", &content).await;
1928    }
1929
1930    #[tokio::test]
1931    async fn write_stream_is_canonical() {
1932        let mfs = mfs().await;
1933
1934        let content = fill(700_000, 11);
1935        let chunks: Vec<bytes::Bytes> = content
1936            .chunks(33_333)
1937            .map(bytes::Bytes::copy_from_slice)
1938            .collect();
1939        let stream = futures::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
1940        mfs.write_stream("/streamed", stream, true).await.unwrap();
1941        assert_canonical(&mfs, "/streamed", &content).await;
1942
1943        let empty = futures::stream::iter(Vec::<std::io::Result<bytes::Bytes>>::new());
1944        mfs.write_stream("/empty", empty, false).await.unwrap();
1945        assert_canonical(&mfs, "/empty", &[]).await;
1946    }
1947
1948    #[tokio::test]
1949    async fn write_stream_reports_progress() {
1950        use futures::StreamExt as _;
1951        let mfs = mfs().await;
1952
1953        let content = fill(700_000, 12);
1954        let chunks: Vec<bytes::Bytes> = content
1955            .chunks(50_000)
1956            .map(bytes::Bytes::copy_from_slice)
1957            .collect();
1958        let stream = futures::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
1959
1960        let mut write = mfs.write_stream("/big", stream, true);
1961        let mut last = 0u64;
1962        let mut completed = None;
1963        while let Some(status) = write.next().await {
1964            match status {
1965                WriteStatus::Progress { written, total } => {
1966                    assert!(written >= last);
1967                    assert_eq!(total, None);
1968                    last = written;
1969                }
1970                WriteStatus::Completed { cid } => completed = Some(cid),
1971                WriteStatus::Failed { error } => panic!("write failed: {error}"),
1972            }
1973        }
1974        let (expected, _, _) = encode_file(&content).unwrap();
1975        assert_eq!(completed, Some(expected));
1976        assert_eq!(last, content.len() as u64);
1977        assert_canonical(&mfs, "/big", &content).await;
1978    }
1979
1980    #[tokio::test]
1981    async fn write_from_file_roundtrips_with_total() {
1982        use futures::StreamExt as _;
1983        let mfs = mfs().await;
1984
1985        let content = fill(400_000, 13);
1986        let file = std::env::temp_dir().join("rust_ipfs_mfs_write_from_file_test.bin");
1987        std::fs::write(&file, &content).unwrap();
1988
1989        let cid = mfs.write_from_file("/imported", &file, true).await.unwrap();
1990        let (expected, _, _) = encode_file(&content).unwrap();
1991        assert_eq!(cid, expected);
1992        assert_canonical(&mfs, "/imported", &content).await;
1993
1994        let mut write = mfs.write_from_file("/imported2", &file, true);
1995        let mut saw_total = false;
1996        while let Some(status) = write.next().await {
1997            if let WriteStatus::Progress { total, .. } = status {
1998                assert_eq!(total, Some(content.len() as u64));
1999                saw_total = true;
2000            }
2001        }
2002        assert!(saw_total);
2003
2004        assert!(
2005            mfs.write_from_file("/x", file.join("missing"), false)
2006                .await
2007                .is_err()
2008        );
2009
2010        std::fs::remove_file(&file).ok();
2011    }
2012
2013    #[tokio::test]
2014    async fn read_stream_roundtrips() {
2015        use futures::StreamExt as _;
2016        let mfs = mfs().await;
2017        let content = fill(600_000, 21);
2018        mfs.write("/f", &content, true).await.unwrap();
2019
2020        let stream = mfs.read_stream("/f");
2021        futures::pin_mut!(stream);
2022        let mut out = Vec::new();
2023        while let Some(chunk) = stream.next().await {
2024            out.extend_from_slice(&chunk.unwrap());
2025        }
2026        assert_eq!(out, content);
2027
2028        mfs.mkdir("/d", false).await.unwrap();
2029        let dir_stream = mfs.read_stream("/d");
2030        futures::pin_mut!(dir_stream);
2031        assert!(dir_stream.next().await.unwrap().is_err());
2032    }
2033
2034    #[tokio::test]
2035    async fn read_to_file_roundtrips_with_progress() {
2036        use futures::StreamExt as _;
2037        let mfs = mfs().await;
2038        let content = fill(500_000, 22);
2039        mfs.write("/f", &content, true).await.unwrap();
2040
2041        let dest = std::env::temp_dir().join("rust_ipfs_mfs_read_to_file_test.bin");
2042        std::fs::remove_file(&dest).ok();
2043
2044        let written = mfs.read_to_file("/f", &dest).await.unwrap();
2045        assert_eq!(written, content.len() as u64);
2046        assert_eq!(std::fs::read(&dest).unwrap(), content);
2047
2048        let mut read = mfs.read_to_file("/f", &dest);
2049        let mut last = 0u64;
2050        while let Some(status) = read.next().await {
2051            match status {
2052                ReadStatus::Progress { written, total } => {
2053                    assert!(written >= last);
2054                    assert_eq!(total, content.len() as u64);
2055                    last = written;
2056                }
2057                ReadStatus::Completed { written } => assert_eq!(written, content.len() as u64),
2058                ReadStatus::Failed { error } => panic!("read failed: {error}"),
2059            }
2060        }
2061        assert_eq!(last, content.len() as u64);
2062
2063        std::fs::remove_file(&dest).ok();
2064    }
2065
2066    #[tokio::test]
2067    async fn empty_file_grow_is_canonical() {
2068        let mfs = mfs().await;
2069
2070        mfs.write("/e", b"", true).await.unwrap();
2071        let data = fill(CHUNK as usize + 5, 4);
2072        mfs.write_with(
2073            "/e",
2074            &data,
2075            WriteOptions {
2076                offset: 0,
2077                create: true,
2078                ..Default::default()
2079            },
2080        )
2081        .await
2082        .unwrap();
2083        assert_canonical(&mfs, "/e", &data).await;
2084
2085        mfs.write("/t", &fill(500_000, 5), true).await.unwrap();
2086        mfs.truncate("/t", 0).await.unwrap();
2087        let grow = fill(CHUNK as usize + 5, 6);
2088        mfs.write_with(
2089            "/t",
2090            &grow,
2091            WriteOptions {
2092                offset: 0,
2093                ..Default::default()
2094            },
2095        )
2096        .await
2097        .unwrap();
2098        assert_canonical(&mfs, "/t", &grow).await;
2099    }
2100
2101    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2102    async fn concurrent_writes_to_same_file_dont_lose_updates() {
2103        let repo = Repo::new_memory();
2104        repo.init().await.unwrap();
2105        let mfs = Mfs::new(repo);
2106        mfs.write("/f", &vec![0u8; 2000], true).await.unwrap();
2107
2108        let a = mfs.clone();
2109        let b = mfs.clone();
2110        let ha = tokio::spawn(async move {
2111            a.write_with(
2112                "/f",
2113                &[1, 1, 1, 1],
2114                WriteOptions {
2115                    offset: 0,
2116                    ..Default::default()
2117                },
2118            )
2119            .await
2120        });
2121        let hb = tokio::spawn(async move {
2122            b.write_with(
2123                "/f",
2124                &[2, 2, 2, 2],
2125                WriteOptions {
2126                    offset: 1000,
2127                    ..Default::default()
2128                },
2129            )
2130            .await
2131        });
2132        ha.await.unwrap().unwrap();
2133        hb.await.unwrap().unwrap();
2134
2135        let content = mfs.read("/f").await.unwrap();
2136        assert_eq!(&content[0..4], &[1, 1, 1, 1], "write A was lost");
2137        assert_eq!(&content[1000..1004], &[2, 2, 2, 2], "write B was lost");
2138        assert_eq!(content.len(), 2000);
2139    }
2140
2141    #[tokio::test]
2142    async fn imported_nonstandard_chunk_file_falls_back() {
2143        use rust_unixfs::file::adder::{Chunker, FileAdder};
2144        let repo = Repo::new_memory();
2145        repo.init().await.unwrap();
2146        let mfs = Mfs::new(repo.clone());
2147
2148        // a file chunked at 400KiB (leaves larger than our 256KiB CHUNK), imported by reference
2149        let content = fill(900_000, 7);
2150        let mut adder = FileAdder::builder()
2151            .with_chunker(Chunker::Size(400 * 1024))
2152            .with_cid_version(VERSION)
2153            .with_hasher(HASHER)
2154            .build();
2155        let mut blocks = Vec::new();
2156        let mut root = None;
2157        let mut off = 0;
2158        while off < content.len() {
2159            let (ready, consumed) = adder.push(&content[off..]);
2160            for (cid, block) in ready {
2161                root = Some(cid);
2162                blocks.push(Block::new(cid, block).unwrap());
2163            }
2164            off += consumed;
2165        }
2166        for (cid, block) in adder.finish() {
2167            root = Some(cid);
2168            blocks.push(Block::new(cid, block).unwrap());
2169        }
2170        let root = root.unwrap();
2171        repo.put_blocks(blocks).await.unwrap();
2172
2173        mfs.cp(&format!("/ipfs/{root}"), "/imported", true)
2174            .await
2175            .unwrap();
2176
2177        // grow it: the 400KiB leaves straddle our 256KiB boundary, so the editor must bail to
2178        // read-modify-rewrite, which re-encodes canonically (correct content, our chunking)
2179        let app = fill(100_000, 8);
2180        let mut expected = content.clone();
2181        expected.extend_from_slice(&app);
2182        mfs.write_with(
2183            "/imported",
2184            &app,
2185            WriteOptions {
2186                offset: content.len() as u64,
2187                ..Default::default()
2188            },
2189        )
2190        .await
2191        .unwrap();
2192        assert_canonical(&mfs, "/imported", &expected).await;
2193    }
2194
2195    #[tokio::test]
2196    async fn write_read_multiblock() {
2197        let mfs = mfs().await;
2198        // larger than the default chunk size so the file becomes a multi-block DAG
2199        let data: Vec<u8> = (0..1_000_000u32).map(|i| i as u8).collect();
2200
2201        mfs.write("/big.bin", &data, false).await.unwrap();
2202        assert_eq!(mfs.read("/big.bin").await.unwrap(), data);
2203
2204        match mfs.stat("/big.bin").await.unwrap().kind {
2205            MfsKind::File { size } => assert_eq!(size, data.len() as u64),
2206            other => panic!("expected file, got {other:?}"),
2207        }
2208    }
2209
2210    #[tokio::test]
2211    async fn large_dir_shards_and_roundtrips() {
2212        let repo = Repo::new_memory();
2213        repo.init().await.unwrap();
2214        let mfs = Mfs::new(repo).with_shard_threshold(Some(0));
2215
2216        let n = 64usize;
2217        for i in 0..n {
2218            mfs.write(
2219                &format!("/d/file{i:03}"),
2220                format!("content {i}").as_bytes(),
2221                true,
2222            )
2223            .await
2224            .unwrap();
2225        }
2226
2227        let cid = mfs.stat("/d").await.unwrap().cid;
2228        let block = mfs.get_block(&cid).await.unwrap();
2229        assert!(
2230            matches!(describe(block.data()), NodeDescription::HamtShard { .. }),
2231            "directory should be HAMT-sharded"
2232        );
2233
2234        let entries = mfs.ls("/d").await.unwrap();
2235        assert_eq!(entries.len(), n);
2236
2237        assert_eq!(mfs.read("/d/file007").await.unwrap(), b"content 7");
2238        assert_eq!(mfs.read("/d/file063").await.unwrap(), b"content 63");
2239
2240        mfs.write("/d/file064", b"new", false).await.unwrap();
2241        assert_eq!(mfs.ls("/d").await.unwrap().len(), n + 1);
2242
2243        mfs.rm("/d/file007", false).await.unwrap();
2244        assert_eq!(mfs.ls("/d").await.unwrap().len(), n);
2245        assert!(mfs.read("/d/file007").await.is_err());
2246    }
2247
2248    #[tokio::test]
2249    async fn cp_from_ipfs_through_hamt_dir() {
2250        let repo = Repo::new_memory();
2251        repo.init().await.unwrap();
2252        let mfs = Mfs::new(repo).with_shard_threshold(Some(0));
2253
2254        for i in 0..64u32 {
2255            mfs.write(
2256                &format!("/d/file{i:03}"),
2257                format!("content {i}").as_bytes(),
2258                true,
2259            )
2260            .await
2261            .unwrap();
2262        }
2263
2264        let dir_cid = mfs.stat("/d").await.unwrap().cid;
2265        let block = mfs.get_block(&dir_cid).await.unwrap();
2266        assert!(
2267            matches!(describe(block.data()), NodeDescription::HamtShard { .. }),
2268            "source dir should be HAMT-sharded"
2269        );
2270
2271        mfs.cp(&format!("/ipfs/{dir_cid}/file042"), "/copied", false)
2272            .await
2273            .unwrap();
2274        assert_eq!(mfs.read("/copied").await.unwrap(), b"content 42");
2275
2276        assert!(
2277            mfs.cp(&format!("/ipfs/{dir_cid}/missing"), "/x", false)
2278                .await
2279                .is_err()
2280        );
2281    }
2282
2283    #[tokio::test]
2284    async fn cp_from_ipfs() {
2285        let repo = Repo::new_memory();
2286        repo.init().await.unwrap();
2287        let mfs = Mfs::new(repo.clone());
2288
2289        let (file_cid, _, blocks) = encode_file(b"imported content").unwrap();
2290        repo.put_blocks(blocks).await.unwrap();
2291
2292        mfs.cp(&format!("/ipfs/{file_cid}"), "/imported.txt", true)
2293            .await
2294            .unwrap();
2295        assert_eq!(
2296            mfs.read("/imported.txt").await.unwrap(),
2297            b"imported content"
2298        );
2299        assert!(matches!(
2300            mfs.stat("/imported.txt").await.unwrap().kind,
2301            MfsKind::File { .. }
2302        ));
2303
2304        let (greeting_cid, greeting_tsize, gblocks) = encode_file(b"hi").unwrap();
2305        repo.put_blocks(gblocks).await.unwrap();
2306        let mut dirmap = DirMap::new();
2307        dirmap.insert(
2308            "greeting".into(),
2309            DirEntry {
2310                cid: greeting_cid,
2311                tsize: greeting_tsize,
2312            },
2313        );
2314        let (dir_cid, _, dblocks) = encode_dir(&dirmap, None).unwrap();
2315        repo.put_blocks(dblocks).await.unwrap();
2316
2317        mfs.cp(&format!("/ipfs/{dir_cid}"), "/srcdir", false)
2318            .await
2319            .unwrap();
2320        assert_eq!(mfs.ls("/srcdir").await.unwrap().len(), 1);
2321
2322        mfs.cp(&format!("/ipfs/{dir_cid}/greeting"), "/hi.txt", false)
2323            .await
2324            .unwrap();
2325        assert_eq!(mfs.read("/hi.txt").await.unwrap(), b"hi");
2326    }
2327
2328    #[tokio::test]
2329    async fn rm_cp_mv() {
2330        let mfs = mfs().await;
2331        mfs.write("/a/file", b"data", true).await.unwrap();
2332
2333        // cp duplicates
2334        mfs.cp("/a/file", "/a/copy", false).await.unwrap();
2335        assert_eq!(mfs.read("/a/copy").await.unwrap(), b"data");
2336        assert_eq!(mfs.read("/a/file").await.unwrap(), b"data");
2337
2338        // mv relocates (source gone, dest present)
2339        mfs.mkdir("/b", false).await.unwrap();
2340        mfs.mv("/a/copy", "/b/moved", false).await.unwrap();
2341        assert_eq!(mfs.read("/b/moved").await.unwrap(), b"data");
2342        assert!(mfs.read("/a/copy").await.is_err());
2343
2344        // rm removes
2345        mfs.rm("/a/file", false).await.unwrap();
2346        assert!(mfs.read("/a/file").await.is_err());
2347
2348        // non-empty dir requires recursive
2349        assert!(mfs.rm("/b", false).await.is_err());
2350        mfs.rm("/b", true).await.unwrap();
2351        assert!(mfs.ls("/b").await.is_err());
2352    }
2353
2354    #[tokio::test]
2355    async fn mkdir_without_parents_errors_on_missing_parent() {
2356        let mfs = mfs().await;
2357        assert!(mfs.mkdir("/x/y", false).await.is_err());
2358        mfs.mkdir("/x", false).await.unwrap();
2359        mfs.mkdir("/x/y", false).await.unwrap();
2360        assert!(
2361            mfs.mkdir("/x/y", false).await.is_err(),
2362            "duplicate mkdir must fail"
2363        );
2364    }
2365
2366    #[tokio::test]
2367    async fn mfs_tree_survives_gc() {
2368        let repo = Repo::new_memory();
2369        repo.init().await.unwrap();
2370        let mfs = Mfs::new(repo.clone());
2371
2372        mfs.write("/a/keep.txt", b"survive gc", true).await.unwrap();
2373
2374        // GC removes everything not pinned; the recursively-pinned MFS root must keep the tree alive
2375        repo.cleanup().await.unwrap();
2376
2377        assert_eq!(mfs.read("/a/keep.txt").await.unwrap(), b"survive gc");
2378        assert_eq!(mfs.ls("/a").await.unwrap().len(), 1);
2379    }
2380
2381    #[tokio::test]
2382    async fn root_persists_across_handles() {
2383        let repo = Repo::new_memory();
2384        repo.init().await.unwrap();
2385
2386        let root_cid = {
2387            let mfs = Mfs::new(repo.clone());
2388            mfs.mkdir("/keep", true).await.unwrap();
2389            mfs.root().await.unwrap().unwrap()
2390        };
2391
2392        // a fresh handle on the same repo reads the persisted root and sees the directory
2393        let reopened = Mfs::new(repo);
2394        assert_eq!(reopened.root().await.unwrap(), Some(root_cid));
2395        let entries = reopened.ls("/").await.unwrap();
2396        assert_eq!(entries.len(), 1);
2397        assert_eq!(entries[0].name, "keep");
2398    }
2399
2400    #[tokio::test]
2401    async fn read_range_partial() {
2402        let mfs = mfs().await;
2403        let content = fill(600_000, 41);
2404        mfs.write("/f", &content, true).await.unwrap();
2405
2406        assert_eq!(
2407            mfs.read_range("/f", 100, Some(50)).await.unwrap(),
2408            content[100..150]
2409        );
2410        assert_eq!(
2411            mfs.read_range("/f", 261_000, Some(2000)).await.unwrap(),
2412            content[261_000..263_000],
2413            "range crossing a chunk boundary"
2414        );
2415        assert_eq!(
2416            mfs.read_range("/f", 590_000, None).await.unwrap(),
2417            content[590_000..]
2418        );
2419        assert_eq!(mfs.read_range("/f", 0, None).await.unwrap(), content);
2420        assert_eq!(
2421            mfs.read_range("/f", 599_990, Some(1000)).await.unwrap(),
2422            content[599_990..],
2423            "count past EOF clamps"
2424        );
2425        assert!(
2426            mfs.read_range("/f", 600_000, Some(10))
2427                .await
2428                .unwrap()
2429                .is_empty()
2430        );
2431        assert!(
2432            mfs.read_range("/f", 10_000_000, Some(5))
2433                .await
2434                .unwrap()
2435                .is_empty()
2436        );
2437
2438        mfs.write("/s", b"hello", true).await.unwrap();
2439        assert_eq!(mfs.read_range("/s", 1, Some(3)).await.unwrap(), b"ell");
2440        assert_eq!(mfs.read_range("/s", 2, None).await.unwrap(), b"llo");
2441        assert!(mfs.read_range("/s", 9, Some(1)).await.unwrap().is_empty());
2442
2443        let stream = mfs.read_stream_range("/f", 1000, Some(500));
2444        futures::pin_mut!(stream);
2445        let mut out = Vec::new();
2446        while let Some(chunk) = stream.next().await {
2447            out.extend_from_slice(&chunk.unwrap());
2448        }
2449        assert_eq!(out, content[1000..1500]);
2450    }
2451
2452    #[tokio::test]
2453    async fn cp_mv_into_existing_directory() {
2454        let mfs = mfs().await;
2455        mfs.write("/src/file.txt", b"hi", true).await.unwrap();
2456
2457        mfs.mkdir("/dst", false).await.unwrap();
2458        mfs.cp("/src/file.txt", "/dst", false).await.unwrap();
2459        assert_eq!(mfs.read("/dst/file.txt").await.unwrap(), b"hi");
2460
2461        mfs.mkdir("/d2", false).await.unwrap();
2462        mfs.cp("/src/file.txt", "/d2/", false).await.unwrap();
2463        assert_eq!(
2464            mfs.read("/d2/file.txt").await.unwrap(),
2465            b"hi",
2466            "trailing slash targets the dir"
2467        );
2468
2469        mfs.mkdir("/dst3", false).await.unwrap();
2470        mfs.mv("/src/file.txt", "/dst3", false).await.unwrap();
2471        assert_eq!(mfs.read("/dst3/file.txt").await.unwrap(), b"hi");
2472        assert!(!mfs.exists("/src/file.txt").await.unwrap());
2473    }
2474
2475    #[tokio::test]
2476    async fn cp_onto_existing_errors_mv_replaces() {
2477        let mfs = mfs().await;
2478        mfs.write("/a.txt", b"aaa", true).await.unwrap();
2479        mfs.write("/b.txt", b"bbb", true).await.unwrap();
2480
2481        let err = mfs.cp("/a.txt", "/b.txt", false).await.unwrap_err();
2482        assert!(matches!(
2483            err.downcast_ref::<MfsError>(),
2484            Some(MfsError::AlreadyExists(_))
2485        ));
2486        assert_eq!(mfs.read("/b.txt").await.unwrap(), b"bbb");
2487
2488        mfs.mv("/a.txt", "/b.txt", false).await.unwrap();
2489        assert_eq!(mfs.read("/b.txt").await.unwrap(), b"aaa");
2490        assert!(!mfs.exists("/a.txt").await.unwrap());
2491    }
2492
2493    #[tokio::test]
2494    async fn mv_self_and_descendant_are_guarded() {
2495        let mfs = mfs().await;
2496        mfs.write("/d/f.txt", b"data", true).await.unwrap();
2497
2498        mfs.mv("/d/f.txt", "/d/f.txt", false).await.unwrap();
2499        assert_eq!(
2500            mfs.read("/d/f.txt").await.unwrap(),
2501            b"data",
2502            "self-move must not destroy"
2503        );
2504
2505        mfs.mv("/d/f.txt", "/d", false).await.unwrap();
2506        assert_eq!(
2507            mfs.read("/d/f.txt").await.unwrap(),
2508            b"data",
2509            "move into own parent is a no-op"
2510        );
2511
2512        mfs.mkdir("/d/sub", true).await.unwrap();
2513        assert!(
2514            mfs.mv("/d", "/d/sub/inner", false).await.is_err(),
2515            "into own descendant"
2516        );
2517        assert_eq!(
2518            mfs.read("/d/f.txt").await.unwrap(),
2519            b"data",
2520            "subtree preserved"
2521        );
2522    }
2523
2524    #[tokio::test]
2525    async fn rm_force_is_idempotent() {
2526        let mfs = mfs().await;
2527        mfs.write("/x.txt", b"x", true).await.unwrap();
2528
2529        mfs.rm_force("/x.txt", false).await.unwrap();
2530        assert!(!mfs.exists("/x.txt").await.unwrap());
2531        mfs.rm_force("/x.txt", false).await.unwrap();
2532        mfs.rm_force("/never/existed", false).await.unwrap();
2533        assert!(
2534            mfs.rm("/x.txt", false).await.is_err(),
2535            "plain rm still errors on missing"
2536        );
2537
2538        mfs.write("/dir/child", b"c", true).await.unwrap();
2539        assert!(
2540            mfs.rm_force("/dir", false).await.is_err(),
2541            "non-empty still needs recursive"
2542        );
2543        mfs.rm_force("/dir", true).await.unwrap();
2544        assert!(!mfs.exists("/dir").await.unwrap());
2545    }
2546
2547    #[tokio::test]
2548    async fn ls_on_file_lists_itself() {
2549        let mfs = mfs().await;
2550        mfs.write("/docs/f.txt", b"hello", true).await.unwrap();
2551
2552        let entries = mfs.ls("/docs/f.txt").await.unwrap();
2553        assert_eq!(entries.len(), 1);
2554        assert_eq!(entries[0].name, "f.txt");
2555        assert_eq!(entries[0].kind, MfsKind::File { size: 5 });
2556        assert_eq!(entries[0].size, 5);
2557    }
2558
2559    #[tokio::test]
2560    async fn exists_reports_presence() {
2561        let mfs = mfs().await;
2562        mfs.mkdir("/a/b", true).await.unwrap();
2563        mfs.write("/a/f.txt", b"x", true).await.unwrap();
2564
2565        assert!(mfs.exists("/").await.unwrap());
2566        assert!(mfs.exists("/a").await.unwrap());
2567        assert!(mfs.exists("/a/b").await.unwrap());
2568        assert!(mfs.exists("/a/f.txt").await.unwrap());
2569        assert!(!mfs.exists("/a/nope").await.unwrap());
2570        assert!(!mfs.exists("/nope/deep").await.unwrap());
2571    }
2572
2573    #[tokio::test]
2574    async fn stat_reports_cumulative_and_blocks() {
2575        let mfs = mfs().await;
2576        let content = fill(600_000, 51);
2577        mfs.write("/big.bin", &content, true).await.unwrap();
2578
2579        let root = mfs.stat("/").await.unwrap();
2580        assert_eq!(root.kind, MfsKind::Directory);
2581        assert!(root.size > 0, "root size must reflect its contents, not 0");
2582        assert_eq!(root.size, root.cumulative_size);
2583
2584        let st = mfs.stat("/big.bin").await.unwrap();
2585        assert_eq!(st.kind, MfsKind::File { size: 600_000 });
2586        assert!(st.cumulative_size > st.size, "dag carries link overhead");
2587        assert!(
2588            st.blocks > 1,
2589            "a multiblock file has multiple leaf links, got {}",
2590            st.blocks
2591        );
2592
2593        mfs.write("/s", b"hi", true).await.unwrap();
2594        assert_eq!(
2595            mfs.stat("/s").await.unwrap().blocks,
2596            0,
2597            "a raw leaf has no links"
2598        );
2599    }
2600
2601    #[tokio::test]
2602    async fn write_stream_error_mid_stream_leaves_path_untouched() {
2603        use futures::StreamExt as _;
2604        let mfs = mfs().await;
2605
2606        let chunks: Vec<std::io::Result<bytes::Bytes>> = vec![
2607            Ok(bytes::Bytes::from_static(b"good")),
2608            Err(std::io::Error::other("boom")),
2609        ];
2610        let mut write = mfs.write_stream("/partial", futures::stream::iter(chunks), false);
2611        let mut failed = 0;
2612        let mut completed = false;
2613        while let Some(status) = write.next().await {
2614            match status {
2615                WriteStatus::Failed { .. } => failed += 1,
2616                WriteStatus::Completed { .. } => completed = true,
2617                WriteStatus::Progress { .. } => {}
2618            }
2619        }
2620        assert_eq!(failed, 1);
2621        assert!(!completed);
2622        assert!(
2623            !mfs.exists("/partial").await.unwrap(),
2624            "failed write must not create the path"
2625        );
2626
2627        let chunks2: Vec<std::io::Result<bytes::Bytes>> = vec![
2628            Ok(bytes::Bytes::from_static(b"x")),
2629            Err(std::io::Error::other("boom")),
2630        ];
2631        assert!(
2632            mfs.write_stream("/partial2", futures::stream::iter(chunks2), false)
2633                .await
2634                .is_err()
2635        );
2636        assert!(!mfs.exists("/partial2").await.unwrap());
2637    }
2638
2639    fn encode_file_bf(data: &[u8], bf: usize) -> (Cid, Vec<Block>) {
2640        use rust_unixfs::file::adder::BalancedCollector;
2641        let mut adder = FileAdder::builder()
2642            .with_cid_version(VERSION)
2643            .with_hasher(HASHER)
2644            .with_collector(BalancedCollector::with_branching_factor(bf))
2645            .build();
2646        let mut blocks = Vec::new();
2647        let mut root = None;
2648        let mut off = 0;
2649        while off < data.len() {
2650            let (ready, consumed) = adder.push(&data[off..]);
2651            for (cid, block) in ready {
2652                root = Some(cid);
2653                blocks.push(Block::new(cid, block).unwrap());
2654            }
2655            off += consumed;
2656        }
2657        for (cid, block) in adder.finish() {
2658            root = Some(cid);
2659            blocks.push(Block::new(cid, block).unwrap());
2660        }
2661        (root.unwrap(), blocks)
2662    }
2663
2664    #[tokio::test]
2665    async fn deep_tree_editor_overwrites_and_grows() {
2666        let repo = Repo::new_memory();
2667        repo.init().await.unwrap();
2668        let mfs = Mfs::new(repo.clone());
2669
2670        let bf = 4;
2671        let content = fill(20 * CHUNK as usize, 31);
2672        let (root, blocks) = encode_file_bf(&content, bf);
2673        repo.put_blocks(blocks).await.unwrap();
2674        mfs.cp(&format!("/ipfs/{root}"), "/deep", true)
2675            .await
2676            .unwrap();
2677
2678        let block = mfs.get_block(&root).await.unwrap();
2679        let branch = parse_file_branch(block.data()).expect("root is a file branch");
2680        let child = mfs.get_block(&branch.links[0].cid).await.unwrap();
2681        assert!(
2682            parse_file_branch(child.data()).is_some(),
2683            "tree must be deeper than two levels to exercise the recursive editor"
2684        );
2685
2686        let patch = b"DEEP-OVERWRITE-PATCH";
2687        let off = 7 * CHUNK as usize + 100;
2688        mfs.write_with(
2689            "/deep",
2690            patch,
2691            WriteOptions {
2692                offset: off as u64,
2693                ..Default::default()
2694            },
2695        )
2696        .await
2697        .unwrap();
2698        let mut expected = content.clone();
2699        expected[off..off + patch.len()].copy_from_slice(patch);
2700        let (exp_root, _) = encode_file_bf(&expected, bf);
2701        assert_eq!(
2702            mfs.stat("/deep").await.unwrap().cid,
2703            exp_root,
2704            "overwrite preserves the deep structure canonically"
2705        );
2706        assert_eq!(mfs.read("/deep").await.unwrap(), expected);
2707
2708        let app = fill(50_000, 32);
2709        mfs.write_with(
2710            "/deep",
2711            &app,
2712            WriteOptions {
2713                offset: expected.len() as u64,
2714                ..Default::default()
2715            },
2716        )
2717        .await
2718        .unwrap();
2719        expected.extend_from_slice(&app);
2720        assert_canonical(&mfs, "/deep", &expected).await;
2721    }
2722
2723    #[tokio::test]
2724    async fn mv_into_dir_does_not_clobber_colliding_entry() {
2725        let mfs = mfs().await;
2726        mfs.write("/a/x/keep", b"src", true).await.unwrap();
2727        mfs.write("/b/x/old", b"victim", true).await.unwrap();
2728
2729        let err = mfs.mv("/a/x", "/b", false).await.unwrap_err();
2730        assert!(matches!(
2731            err.downcast_ref::<MfsError>(),
2732            Some(MfsError::AlreadyExists(_))
2733        ));
2734        assert_eq!(
2735            mfs.read("/b/x/old").await.unwrap(),
2736            b"victim",
2737            "destination subtree survives"
2738        );
2739        assert_eq!(
2740            mfs.read("/a/x/keep").await.unwrap(),
2741            b"src",
2742            "source preserved on failure"
2743        );
2744    }
2745
2746    #[tokio::test]
2747    async fn mv_missing_source_errors() {
2748        let mfs = mfs().await;
2749        mfs.mkdir("/d", false).await.unwrap();
2750        assert!(
2751            mfs.mv("/d/missing", "/d", false).await.is_err(),
2752            "no false success via no-op guard"
2753        );
2754        assert!(mfs.mv("/nope", "/nope", false).await.is_err());
2755    }
2756
2757    #[tokio::test]
2758    async fn rm_force_propagates_structural_errors() {
2759        let mfs = mfs().await;
2760        mfs.write("/a", b"file", true).await.unwrap();
2761        // /a is a file, so /a/b treats a file as an intermediate directory, a real error, not absence
2762        assert!(mfs.rm_force("/a/b", false).await.is_err());
2763        assert_eq!(mfs.read("/a").await.unwrap(), b"file");
2764    }
2765
2766    #[tokio::test]
2767    async fn root_cumulative_size_is_monotonic() {
2768        let mfs = mfs().await;
2769        let big = fill(600_000, 61);
2770        mfs.write("/dir/a", &big, true).await.unwrap();
2771        mfs.cp("/dir/a", "/dir/b", false).await.unwrap();
2772
2773        let root = mfs.stat("/").await.unwrap().cumulative_size;
2774        let dir = mfs.stat("/dir").await.unwrap().cumulative_size;
2775        assert!(
2776            root >= dir,
2777            "root cumulative {root} must be >= its child dir {dir}"
2778        );
2779    }
2780}