Skip to main content

mitosis/clone_backend/
reflink.rs

1//! Reflink back end for Btrfs and XFS: the tree is walked and each regular file is cloned with
2//! the `FICLONE` ioctl, so a clone shares every block with its source until one side writes.
3
4use super::{
5    clone_tree, create_dst, fs_create_dir, fs_remove_dir, fs_rename_dir, fs_type_name, io_error,
6    unsupported, walk_files, CloneBackend,
7};
8use crate::error::{ErrorCode, MitosisError, Result};
9use std::fs::{File, Metadata, OpenOptions};
10use std::io::Write;
11use std::os::fd::AsRawFd;
12use std::os::unix::fs::OpenOptionsExt;
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16const REFLINK_HINT: &str =
17    "put MITOSIS_HOME on Btrfs or XFS (mkfs.xfs -m reflink=1), or set backend = \"copy\"";
18
19// Not exported by libc; values from <linux/fs.h> and <linux/fiemap.h>.
20const FS_IOC_FIEMAP: u32 = 0xC020_660B;
21const FIEMAP_EXTENT_LAST: u32 = 0x0001;
22const FIEMAP_EXTENT_SHARED: u32 = 0x2000;
23const FIEMAP_BATCH: usize = 64;
24
25#[repr(C)]
26#[derive(Clone, Copy)]
27#[allow(dead_code)] // the kernel fills fields we never read
28struct FiemapExtent {
29    fe_logical: u64,
30    fe_physical: u64,
31    fe_length: u64,
32    fe_reserved64: [u64; 2],
33    fe_flags: u32,
34    fe_reserved: [u32; 3],
35}
36
37/// `struct fiemap` with room for `FIEMAP_BATCH` extents after the 32-byte header.
38#[repr(C)]
39#[allow(dead_code)] // the kernel reads fields we only write
40struct Fiemap {
41    fm_start: u64,
42    fm_length: u64,
43    fm_flags: u32,
44    fm_mapped_extents: u32,
45    fm_extent_count: u32,
46    fm_reserved: u32,
47    fm_extents: [FiemapExtent; FIEMAP_BATCH],
48}
49
50pub struct ReflinkBackend;
51
52impl CloneBackend for ReflinkBackend {
53    fn name(&self) -> &'static str {
54        "reflink"
55    }
56
57    fn check_supported(&self, path: &Path) -> Result<()> {
58        // Probes from parallel threads of one process must not share file names.
59        static PROBE: AtomicU64 = AtomicU64::new(0);
60        let tag = format!(
61            ".mitosis-reflink-probe-{}-{}",
62            std::process::id(),
63            PROBE.fetch_add(1, Ordering::Relaxed)
64        );
65        let (a, b) = (path.join(format!("{tag}-a")), path.join(format!("{tag}-b")));
66        let probed = probe(&a, &b);
67        let _ = std::fs::remove_file(&a);
68        let _ = std::fs::remove_file(&b);
69        probed.map_err(|e| {
70            unsupported(
71                format!(
72                    "reflink (FICLONE) does not work on the '{}' file system at {}: {e}",
73                    fs_type_name(path),
74                    path.display()
75                ),
76                REFLINK_HINT,
77            )
78        })
79    }
80
81    fn clone_dir(&self, src: &Path, dst: &Path) -> Result<()> {
82        clone_tree(src, dst, &clone_file)
83    }
84
85    fn create_dir(&self, path: &Path) -> Result<()> {
86        fs_create_dir(path)
87    }
88
89    fn remove_dir(&self, path: &Path) -> Result<()> {
90        fs_remove_dir(path)
91    }
92
93    fn rename_dir(&self, from: &Path, to: &Path) -> Result<()> {
94        fs_rename_dir(from, to)
95    }
96
97    // shortcut: any failure on any file makes the whole sum None rather than a partial number,
98    // as on APFS.
99    fn private_size(&self, path: &Path) -> Option<u64> {
100        let mut total = 0u64;
101        let done = walk_files(path, &mut |p, _| {
102            total += file_private_size(p)?;
103            Some(())
104        });
105        matches!(done, Ok(Some(()))).then_some(total)
106    }
107}
108
109fn ficlone(src: &File, dst: &File) -> std::io::Result<()> {
110    if unsafe { libc::ioctl(dst.as_raw_fd(), libc::FICLONE, src.as_raw_fd()) } == 0 {
111        Ok(())
112    } else {
113        Err(std::io::Error::last_os_error())
114    }
115}
116
117/// Clones a small temp file `a` into `b`: the answer holds for this exact mount.
118fn probe(a: &Path, b: &Path) -> std::io::Result<()> {
119    let mut fa = OpenOptions::new()
120        .read(true)
121        .write(true)
122        .create_new(true)
123        .mode(0o600)
124        .open(a)?;
125    fa.write_all(&[0u8; 4096])?;
126    let fb = OpenOptions::new()
127        .write(true)
128        .create_new(true)
129        .mode(0o600)
130        .open(b)?;
131    ficlone(&fa, &fb)
132}
133
134fn clone_file(src: &Path, dst: &Path, md: &Metadata) -> Result<()> {
135    let s = File::open(src).map_err(|e| io_error("open", src, e))?;
136    let d = create_dst(dst, md)?;
137    ficlone(&s, &d).map_err(|e| {
138        let msg = format!("FICLONE {} -> {}: {e}", src.display(), dst.display());
139        match e.raw_os_error() {
140            Some(libc::EXDEV) => MitosisError::new(ErrorCode::CrossVolume, msg)
141                .with_hint("templates/ and clones/ must be on the same filesystem"),
142            Some(libc::EOPNOTSUPP | libc::EINVAL) => unsupported(msg, REFLINK_HINT),
143            _ => MitosisError::new(ErrorCode::Internal, msg),
144        }
145    })
146}
147
148/// Bytes of one file in extents not marked shared, via `FS_IOC_FIEMAP`; any ioctl error is `None`.
149fn file_private_size(path: &Path) -> Option<u64> {
150    let f = File::open(path).ok()?;
151    let mut fm: Fiemap = unsafe { std::mem::zeroed() };
152    let (mut start, mut total) = (0u64, 0u64);
153    loop {
154        fm.fm_start = start;
155        fm.fm_length = u64::MAX;
156        fm.fm_flags = 0;
157        fm.fm_mapped_extents = 0;
158        fm.fm_extent_count = FIEMAP_BATCH as u32;
159        if unsafe { libc::ioctl(f.as_raw_fd(), FS_IOC_FIEMAP as _, &mut fm as *mut Fiemap) } != 0 {
160            return None;
161        }
162        let n = (fm.fm_mapped_extents as usize).min(FIEMAP_BATCH);
163        if n == 0 {
164            return Some(total);
165        }
166        let extents = &fm.fm_extents[..n];
167        for e in extents {
168            if e.fe_flags & FIEMAP_EXTENT_SHARED == 0 {
169                total += e.fe_length;
170            }
171        }
172        let last = extents[n - 1];
173        let next = last.fe_logical.saturating_add(last.fe_length);
174        if last.fe_flags & FIEMAP_EXTENT_LAST != 0 || next <= start {
175            return Some(total);
176        }
177        start = next;
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::clone_backend::same_volume;
185    use std::fs::Permissions;
186    use std::fs::{create_dir, metadata, read, read_link, read_to_string, symlink_metadata, write};
187    use std::os::unix::fs::symlink;
188    use std::os::unix::fs::{MetadataExt, PermissionsExt};
189    use std::path::PathBuf;
190
191    /// The mounted Btrfs/XFS test file system, only when `scripts/linux-test.sh` runs reflink.
192    fn reflink_root() -> Option<PathBuf> {
193        if std::env::var("MITOSIS_BACKEND").as_deref() != Ok("reflink") {
194            eprintln!("skipped: MITOSIS_BACKEND is not reflink");
195            return None;
196        }
197        Some(
198            std::env::var("MITOSIS_TEST_ROOT")
199                .unwrap_or_else(|_| "/tmp".into())
200                .into(),
201        )
202    }
203
204    #[test]
205    fn reflink_clone_is_isolated_and_shares_blocks() {
206        let Some(root) = reflink_root() else { return };
207        let tmp = tempfile::tempdir_in(root).unwrap();
208        ReflinkBackend.check_supported(tmp.path()).unwrap();
209        let (src, dst) = (tmp.path().join("src"), tmp.path().join("dst"));
210        create_dir(&src).unwrap();
211        let big = src.join("big");
212        write(&big, vec![7u8; 8 << 20]).unwrap();
213        std::fs::set_permissions(&big, Permissions::from_mode(0o640)).unwrap();
214        create_dir(src.join("sub")).unwrap();
215        write(src.join("sub/a.txt"), "hello").unwrap();
216
217        ReflinkBackend.clone_dir(&src, &dst).unwrap();
218        let private = ReflinkBackend.private_size(&dst).unwrap();
219        assert!(private < 64 * 1024, "clone has {private} private bytes");
220        let (smd, dmd) = (metadata(&big).unwrap(), metadata(dst.join("big")).unwrap());
221        assert_eq!(dmd.mode() & 0o7777, 0o640);
222        assert_eq!(dmd.modified().unwrap(), smd.modified().unwrap());
223
224        write(dst.join("sub/a.txt"), "changed").unwrap();
225        let mut f = OpenOptions::new()
226            .write(true)
227            .open(dst.join("big"))
228            .unwrap();
229        f.write_all(b"xxxx").unwrap();
230        drop(f);
231        assert_eq!(read_to_string(src.join("sub/a.txt")).unwrap(), "hello");
232        assert_eq!(read(&big).unwrap()[..4], [7u8; 4]);
233        assert!(ReflinkBackend.clone_dir(&src, &dst).is_err());
234    }
235
236    #[test]
237    fn reflink_keeps_symlinks_unfollowed() {
238        let Some(root) = reflink_root() else { return };
239        let tmp = tempfile::tempdir_in(root).unwrap();
240        let (src, dst) = (tmp.path().join("src"), tmp.path().join("dst"));
241        let wal_real = tmp.path().join("wal_real");
242        create_dir(&wal_real).unwrap();
243        write(wal_real.join("000000010000000000000001"), vec![1u8; 4096]).unwrap();
244        create_dir(&src).unwrap();
245        create_dir(src.join("pg_tblspc")).unwrap();
246        symlink("/elsewhere", src.join("pg_tblspc/16384")).unwrap();
247        symlink(&wal_real, src.join("pg_wal")).unwrap();
248
249        ReflinkBackend.clone_dir(&src, &dst).unwrap();
250        for link in ["pg_tblspc/16384", "pg_wal"] {
251            assert!(symlink_metadata(dst.join(link))
252                .unwrap()
253                .file_type()
254                .is_symlink());
255            assert_eq!(
256                read_link(dst.join(link)).unwrap(),
257                read_link(src.join(link)).unwrap()
258            );
259        }
260        assert_eq!(std::fs::read_dir(&wal_real).unwrap().count(), 1);
261    }
262
263    #[test]
264    fn reflink_cross_filesystem_is_cross_volume() {
265        let Some(root) = reflink_root() else { return };
266        let tmp = tempfile::tempdir_in(root).unwrap();
267        let other = tempfile::tempdir_in("/tmp").unwrap();
268        if same_volume(tmp.path(), other.path()).unwrap() {
269            eprintln!("skipped: /tmp is on the test file system");
270            return;
271        }
272        let src = tmp.path().join("src");
273        create_dir(&src).unwrap();
274        write(src.join("a"), "hello").unwrap();
275        let dst = other.path().join("dst");
276
277        let e = ReflinkBackend.clone_dir(&src, &dst).err().unwrap();
278        assert_eq!(e.code, ErrorCode::CrossVolume, "{e}");
279        assert!(!dst.exists());
280    }
281
282    #[test]
283    fn probe_rejects_non_reflink_fs() {
284        if std::env::var("MITOSIS_TEST_ROOT").is_err() {
285            eprintln!("skipped: not in the Linux test container");
286            return;
287        }
288        let tmp = tempfile::tempdir_in("/tmp").unwrap();
289        let e = ReflinkBackend.check_supported(tmp.path()).err().unwrap();
290        assert_eq!(e.code, ErrorCode::BackendUnsupported, "{e}");
291        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
292    }
293}