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#[derive(Parser, Debug)]
42#[allow(clippy::doc_markdown)]
43pub struct SendCommand {
44 #[clap(required_unless_present = "offline")]
46 subvolumes: Vec<PathBuf>,
47
48 #[clap(short = 'e', long)]
50 omit_end_cmd: bool,
51
52 #[clap(short = 'p', long, help_heading = HEADING_INCREMENTAL)]
54 parent: Option<PathBuf>,
55
56 #[clap(short = 'c', long = "clone-src", help_heading = HEADING_INCREMENTAL)]
58 clone_src: Vec<PathBuf>,
59
60 #[clap(short = 'f', long)]
62 outfile: Option<PathBuf>,
63
64 #[clap(long, help_heading = HEADING_PROTOCOL)]
66 no_data: bool,
67
68 #[clap(long, help_heading = HEADING_PROTOCOL)]
70 proto: Option<u32>,
71
72 #[clap(long, help_heading = HEADING_PROTOCOL)]
74 compressed_data: bool,
75
76 #[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 #[clap(long = "offline-subvol", help_heading = HEADING_OFFLINE,
88 requires = "offline")]
89 offline_subvol: Option<String>,
90
91 #[clap(long = "offline-subvolid", help_heading = HEADING_OFFLINE,
94 requires = "offline", conflicts_with = "offline_subvol")]
95 offline_subvolid: Option<u64>,
96}
97
98const SEND_BUF_SIZE_V1: usize = 64 * 1024;
100const 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
125fn 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 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
166fn 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 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
180fn 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
202fn 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 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 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 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 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 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 if let Some(path) = &self.outfile {
302 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 for subvol_path in &self.subvolumes {
329 let file = open_subvol_ro(subvol_path)?;
330 check_subvol_readonly(&file, subvol_path)?;
331 }
332
333 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 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 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 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 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 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 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 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 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 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 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}