Skip to main content

btrfs_cli/
send.rs

1use crate::{RunContext, Runnable};
2use anyhow::{Context, Result, bail};
3use btrfs_fs::{Filesystem, SubvolId};
4use btrfs_uapi::{
5    send_receive::SendFlags,
6    subvolume::{SubvolumeFlags, subvolume_flags_get, subvolume_info},
7    sysfs::SysfsBtrfs,
8};
9use clap::Parser;
10use std::{
11    fs::File,
12    io::{self, Read, Write},
13    os::{
14        fd::{AsFd, AsRawFd, OwnedFd},
15        unix::io::FromRawFd,
16    },
17    path::{Path, PathBuf},
18    thread,
19};
20
21const HEADING_INCREMENTAL: &str = "Incremental";
22const HEADING_PROTOCOL: &str = "Protocol";
23const HEADING_OFFLINE: &str = "Offline mode";
24
25/// Send the subvolume(s) to stdout.
26///
27/// Generate a stream representation of one or more subvolumes that can be
28/// transmitted over the network or stored for later restoration. Streams
29/// are incremental and can be based on a parent subvolume to only send
30/// changes. The stream output is in btrfs send format and can be received
31/// with the receive command. Requires CAP_SYS_ADMIN.
32///
33/// With --offline, the kernel BTRFS_IOC_SEND ioctl is bypassed entirely
34/// and the stream is generated by reading the image directly via
35/// btrfs-fs. Useful for sending from images that aren't mounted, FUSE
36/// mounts where the ioctl can't complete, and unprivileged scenarios
37/// (no CAP_SYS_ADMIN required). Tier 1 of the send roadmap: full
38/// sends only — incremental, clone sources, NO_FILE_DATA, alternate
39/// protocol versions, and compressed-data passthrough are not yet
40/// supported in offline mode.
41#[derive(Parser, Debug)]
42#[allow(clippy::doc_markdown)]
43pub struct SendCommand {
44    /// Subvolume(s) to send. Required unless --offline is given.
45    #[clap(required_unless_present = "offline")]
46    subvolumes: Vec<PathBuf>,
47
48    /// Omit end-cmd marker between subvolumes
49    #[clap(short = 'e', long)]
50    omit_end_cmd: bool,
51
52    /// Send an incremental stream from parent to the subvolume
53    #[clap(short = 'p', long, help_heading = HEADING_INCREMENTAL)]
54    parent: Option<PathBuf>,
55
56    /// Use this snapshot as a clone source (may be given multiple times)
57    #[clap(short = 'c', long = "clone-src", help_heading = HEADING_INCREMENTAL)]
58    clone_src: Vec<PathBuf>,
59
60    /// Write output to a file instead of stdout
61    #[clap(short = 'f', long)]
62    outfile: Option<PathBuf>,
63
64    /// Send in NO_FILE_DATA mode
65    #[clap(long, help_heading = HEADING_PROTOCOL)]
66    no_data: bool,
67
68    /// Use send protocol version N (0 = highest supported by kernel)
69    #[clap(long, help_heading = HEADING_PROTOCOL)]
70    proto: Option<u32>,
71
72    /// Send compressed data directly without decompressing
73    #[clap(long, help_heading = HEADING_PROTOCOL)]
74    compressed_data: bool,
75
76    /// Path to an unmounted btrfs image or block device to send
77    /// from. Bypasses the kernel ioctl path; requires no
78    /// privileges and works for images that aren't mounted.
79    #[clap(long, value_name = "IMAGE", help_heading = HEADING_OFFLINE,
80           conflicts_with_all = &["parent", "clone_src", "no_data", "compressed_data"])]
81    offline: Option<PathBuf>,
82
83    /// In --offline mode, slash-separated path of the subvolume to
84    /// send (interpreted relative to the filesystem root).
85    /// Defaults to the default subvolume (FS_TREE) when neither
86    /// this nor --offline-subvolid is set.
87    #[clap(long = "offline-subvol", help_heading = HEADING_OFFLINE,
88           requires = "offline")]
89    offline_subvol: Option<String>,
90
91    /// In --offline mode, send the subvolume with this tree id.
92    /// Mutually exclusive with --offline-subvol.
93    #[clap(long = "offline-subvolid", help_heading = HEADING_OFFLINE,
94           requires = "offline", conflicts_with = "offline_subvol")]
95    offline_subvolid: Option<u64>,
96}
97
98/// Buffer size for protocol v1 (matches `BTRFS_SEND_BUF_SIZE_V1` = 64 KiB).
99const SEND_BUF_SIZE_V1: usize = 64 * 1024;
100/// Buffer size for protocol v2+ (16 KiB + 128 KiB compressed = 144 KiB).
101const SEND_BUF_SIZE_V2: usize = 16 * 1024 + 128 * 1024;
102
103fn open_subvol_ro(path: &Path) -> Result<File> {
104    File::open(path)
105        .with_context(|| format!("cannot open '{}'", path.display()))
106}
107
108fn check_subvol_readonly(file: &File, path: &Path) -> Result<()> {
109    let flags = subvolume_flags_get(file.as_fd()).with_context(|| {
110        format!("failed to get flags for '{}'", path.display())
111    })?;
112    if !flags.contains(SubvolumeFlags::RDONLY) {
113        bail!("subvolume '{}' is not read-only", path.display());
114    }
115    Ok(())
116}
117
118fn get_root_id(file: &File, path: &Path) -> Result<u64> {
119    let info = subvolume_info(file.as_fd()).with_context(|| {
120        format!("failed to get subvolume info for '{}'", path.display())
121    })?;
122    Ok(info.id)
123}
124
125/// Find the best parent among clone sources for incremental send.
126///
127/// Looks for a clone source that shares the same parent UUID as the target
128/// subvolume and picks the one with the closest ctransid.
129fn find_good_parent(
130    subvol_info: &btrfs_uapi::subvolume::SubvolumeInfo,
131    clone_source_paths: &[PathBuf],
132) -> Result<Option<u64>> {
133    if subvol_info.parent_uuid.is_nil() {
134        return Ok(None);
135    }
136
137    let mut best_root_id = None;
138    let mut best_diff = u64::MAX;
139
140    for cs_path in clone_source_paths {
141        let cs_file = open_subvol_ro(cs_path)?;
142        let cs_info = subvolume_info(cs_file.as_fd()).with_context(|| {
143            format!(
144                "failed to get info for clone source '{}'",
145                cs_path.display()
146            )
147        })?;
148
149        // Check if this clone source shares the same parent or IS the parent.
150        if cs_info.parent_uuid != subvol_info.parent_uuid
151            && cs_info.uuid != subvol_info.parent_uuid
152        {
153            continue;
154        }
155
156        let diff = subvol_info.ctransid.abs_diff(cs_info.ctransid);
157        if diff < best_diff {
158            best_diff = diff;
159            best_root_id = Some(cs_info.id);
160        }
161    }
162
163    Ok(best_root_id)
164}
165
166/// Create a pipe and return (`read_end`, `write_end`) as `OwnedFd`s.
167fn make_pipe() -> Result<(OwnedFd, OwnedFd)> {
168    let mut fds = [0i32; 2];
169    let ret = unsafe { nix::libc::pipe(fds.as_mut_ptr()) };
170    if ret < 0 {
171        return Err(io::Error::last_os_error())
172            .context("failed to create pipe");
173    }
174    // SAFETY: pipe() just returned two valid fds.
175    let read_end = unsafe { OwnedFd::from_raw_fd(fds[0]) };
176    let write_end = unsafe { OwnedFd::from_raw_fd(fds[1]) };
177    Ok((read_end, write_end))
178}
179
180/// Spawn a thread that reads from `read_fd` and writes everything to `out`.
181fn spawn_reader_thread(
182    read_fd: OwnedFd,
183    mut out: Box<dyn Write + Send>,
184    buf_size: usize,
185) -> thread::JoinHandle<Result<()>> {
186    thread::spawn(move || {
187        let mut file = File::from(read_fd);
188        let mut buf = vec![0u8; buf_size];
189        loop {
190            let n = file
191                .read(&mut buf)
192                .context("failed to read send stream from kernel")?;
193            if n == 0 {
194                return Ok(());
195            }
196            out.write_all(&buf[..n])
197                .context("failed to write send stream to output")?;
198        }
199    })
200}
201
202/// Open or create the output writer for the reader thread.
203fn open_output(outfile: Option<&PathBuf>) -> Result<Box<dyn Write + Send>> {
204    match outfile {
205        Some(path) => {
206            let file =
207                File::options().append(true).open(path).with_context(|| {
208                    format!("cannot open '{}' for writing", path.display())
209                })?;
210            Ok(Box::new(file))
211        }
212        None => Ok(Box::new(io::stdout())),
213    }
214}
215
216impl SendCommand {
217    /// Offline send path: open the image with `btrfs-fs`, resolve the
218    /// requested subvolume, and stream a v1 send dump to `--outfile`
219    /// (or stdout). No kernel ioctl, no privileges. Tier 1 only —
220    /// the validation up front rejects flags we don't yet honour
221    /// in offline mode. (clap's `conflicts_with_all` already keeps
222    /// the obvious ones away; `subvolumes` is accepted but
223    /// reinterpreted, and we error on multiples.)
224    fn run_offline(&self, image: &Path) -> Result<()> {
225        if self.subvolumes.len() > 1 {
226            bail!("--offline supports only a single subvolume per invocation");
227        }
228
229        // Refuse stdout-into-tty just like the ioctl path does — a
230        // send stream is binary, not for human eyes.
231        if self.outfile.is_none() {
232            let stdout = io::stdout();
233            if unsafe { nix::libc::isatty(stdout.as_fd().as_raw_fd()) } == 1 {
234                bail!(
235                    "not dumping send stream into a terminal, redirect it into a file"
236                );
237            }
238        }
239
240        let file = File::open(image)
241            .with_context(|| format!("opening {}", image.display()))?;
242        let fs =
243            Filesystem::open(file).context("bootstrapping btrfs filesystem")?;
244
245        // Resolve the subvolume. Precedence: --offline-subvolid >
246        // --offline-subvol > positional path > default subvol.
247        let runtime = tokio::runtime::Builder::new_current_thread()
248            .enable_all()
249            .build()
250            .context("creating tokio runtime")?;
251        let subvol = if let Some(id) = self.offline_subvolid {
252            SubvolId(id)
253        } else {
254            let path_arg = self
255                .offline_subvol
256                .as_deref()
257                .or_else(|| self.subvolumes.first().and_then(|p| p.to_str()));
258            match path_arg {
259                Some(path) => runtime
260                    .block_on(fs.resolve_subvol_path(path))
261                    .with_context(|| {
262                        format!("resolving subvolume path {path:?}")
263                    })?
264                    .ok_or_else(|| {
265                        anyhow::anyhow!(
266                            "subvolume path {path:?} not found on {}",
267                            image.display(),
268                        )
269                    })?,
270                None => fs.default_subvol(),
271            }
272        };
273
274        // Choose output destination.
275        let output: Box<dyn Write + Send> = if let Some(path) = &self.outfile {
276            Box::new(File::create(path).with_context(|| {
277                format!("cannot create '{}'", path.display())
278            })?)
279        } else {
280            // `Stdout` itself isn't `Write` directly, but
281            // `Stdout::lock` is. Take the lock for the duration of
282            // the send so other threads don't interleave.
283            Box::new(io::BufWriter::new(io::stdout()))
284        };
285
286        runtime
287            .block_on(fs.send(subvol, output))
288            .context("generating send stream")?;
289        Ok(())
290    }
291}
292
293impl Runnable for SendCommand {
294    #[allow(clippy::too_many_lines)]
295    fn run(&self, _ctx: &RunContext) -> Result<()> {
296        if let Some(image) = &self.offline {
297            return self.run_offline(image);
298        }
299
300        // Validate output destination.
301        if let Some(path) = &self.outfile {
302            // Try opening existing file first, then create. Truncate since
303            // this is the start of a new send.
304            File::options()
305                .write(true)
306                .truncate(true)
307                .open(path)
308                .or_else(|_| {
309                    File::options()
310                        .write(true)
311                        .truncate(true)
312                        .create(true)
313                        .open(path)
314                })
315                .with_context(|| {
316                    format!("cannot create '{}'", path.display())
317                })?;
318        } else {
319            let stdout = io::stdout();
320            if unsafe { nix::libc::isatty(stdout.as_fd().as_raw_fd()) } == 1 {
321                bail!(
322                    "not dumping send stream into a terminal, redirect it into a file"
323                );
324            }
325        }
326
327        // Validate all subvolumes are read-only.
328        for subvol_path in &self.subvolumes {
329            let file = open_subvol_ro(subvol_path)?;
330            check_subvol_readonly(&file, subvol_path)?;
331        }
332
333        // Validate parent is read-only and get its root ID.
334        let mut parent_root_id: u64 = 0;
335        if let Some(parent_path) = &self.parent {
336            let file = open_subvol_ro(parent_path)?;
337            check_subvol_readonly(&file, parent_path)?;
338            parent_root_id = get_root_id(&file, parent_path)?;
339        }
340
341        // Collect clone source root IDs and validate they are read-only.
342        let mut clone_sources: Vec<u64> = Vec::new();
343        for cs_path in &self.clone_src {
344            let file = open_subvol_ro(cs_path)?;
345            check_subvol_readonly(&file, cs_path)?;
346            clone_sources.push(get_root_id(&file, cs_path)?);
347        }
348
349        // If a parent was given, add it to clone sources (matches C behavior).
350        if self.parent.is_some() && !clone_sources.contains(&parent_root_id) {
351            clone_sources.push(parent_root_id);
352        }
353
354        let full_send = self.parent.is_none() && self.clone_src.is_empty();
355
356        // Determine protocol version.
357        let first_file = open_subvol_ro(&self.subvolumes[0])?;
358        let fs = btrfs_uapi::filesystem::filesystem_info(first_file.as_fd())
359            .context("failed to get filesystem info")?;
360        let sysfs = SysfsBtrfs::new(&fs.uuid);
361        let proto_supported = sysfs.send_stream_version();
362
363        let mut proto = self.proto.unwrap_or(1);
364        if proto == 0 {
365            proto = proto_supported;
366        }
367
368        if proto > proto_supported && proto_supported == 1 {
369            bail!(
370                "requested protocol version {proto} but kernel supports only {proto_supported}"
371            );
372        }
373
374        // Build send flags.
375        let mut flags = SendFlags::empty();
376        if self.no_data {
377            flags |= SendFlags::NO_FILE_DATA;
378        }
379        if self.compressed_data {
380            if proto == 1 && self.proto.is_none() {
381                proto = 2;
382            }
383            if proto < 2 {
384                bail!(
385                    "--compressed-data requires protocol version >= 2 (requested {proto})"
386                );
387            }
388            if proto_supported < 2 {
389                bail!("kernel does not support --compressed-data");
390            }
391            flags |= SendFlags::COMPRESSED;
392        }
393        if proto_supported > 1 {
394            flags |= SendFlags::VERSION;
395        }
396
397        let buf_size = if proto > 1 {
398            SEND_BUF_SIZE_V2
399        } else {
400            SEND_BUF_SIZE_V1
401        };
402
403        // Send each subvolume.
404        let count = self.subvolumes.len();
405        for (i, subvol_path) in self.subvolumes.iter().enumerate() {
406            let is_first = i == 0;
407            let is_last = i == count - 1;
408
409            eprintln!("At subvol {}", subvol_path.display());
410
411            let subvol_file = open_subvol_ro(subvol_path)?;
412
413            // For incremental send without an explicit parent, find the best
414            // parent among clone sources.
415            let mut this_parent = parent_root_id;
416            if !full_send && self.parent.is_none() {
417                let info =
418                    subvolume_info(subvol_file.as_fd()).with_context(|| {
419                        format!(
420                            "failed to get info for '{}'",
421                            subvol_path.display()
422                        )
423                    })?;
424                match find_good_parent(&info, &self.clone_src)? {
425                    Some(id) => this_parent = id,
426                    None => bail!(
427                        "cannot find a suitable parent for '{}' among clone sources",
428                        subvol_path.display()
429                    ),
430                }
431            }
432
433            // Build per-subvolume flags.
434            let mut subvol_flags = flags;
435            if self.omit_end_cmd {
436                if !is_first {
437                    subvol_flags |= SendFlags::OMIT_STREAM_HEADER;
438                }
439                if !is_last {
440                    subvol_flags |= SendFlags::OMIT_END_CMD;
441                }
442            }
443
444            // Create pipe and spawn reader thread.
445            let (pipe_read, pipe_write) = make_pipe()?;
446            let out = open_output(self.outfile.as_ref())?;
447            let reader = spawn_reader_thread(pipe_read, out, buf_size);
448
449            let send_result = btrfs_uapi::send_receive::send(
450                subvol_file.as_fd(),
451                pipe_write.as_raw_fd(),
452                this_parent,
453                &mut clone_sources,
454                subvol_flags,
455                proto,
456            );
457
458            // Close write end so the reader thread sees EOF.
459            drop(pipe_write);
460
461            if let Err(e) = send_result {
462                let _ = reader.join();
463                if e == nix::errno::Errno::EINVAL && self.omit_end_cmd {
464                    bail!(
465                        "send ioctl failed: {e}\n\
466                         Try upgrading your kernel or don't use -e."
467                    );
468                }
469                return Err(e).with_context(|| {
470                    format!("send failed for '{}'", subvol_path.display())
471                });
472            }
473
474            match reader.join() {
475                Ok(Ok(())) => {}
476                Ok(Err(e)) => {
477                    return Err(e).context("send stream reader failed");
478                }
479                Err(_) => bail!("send stream reader thread panicked"),
480            }
481
482            // After sending, add to clone sources for subsequent subvolumes.
483            if !full_send && self.parent.is_none() {
484                let root_id = get_root_id(&subvol_file, subvol_path)?;
485                if !clone_sources.contains(&root_id) {
486                    clone_sources.push(root_id);
487                }
488            }
489        }
490
491        Ok(())
492    }
493}