Skip to main content

btrfs_stream/
receive.rs

1use crate::stream::{StreamCommand, Timespec};
2use anyhow::{Context, Result, anyhow, bail};
3use btrfs_uapi::{
4    inode::subvolid_resolve,
5    send_receive::{
6        clone_range, encoded_write, received_subvol_set,
7        subvolume_search_by_received_uuid, subvolume_search_by_uuid,
8    },
9    subvolume::{
10        SubvolumeFlags, snapshot_create, subvolume_create, subvolume_flags_get,
11        subvolume_flags_set, subvolume_info,
12    },
13};
14use std::{
15    ffi::CString,
16    fs::{self, File, OpenOptions},
17    io,
18    os::{
19        fd::{AsFd, AsRawFd},
20        unix::fs::PermissionsExt,
21    },
22    path::{Path, PathBuf},
23};
24
25/// Find the mount root for a given path by walking up the directory tree
26/// while the device ID (`st_dev`) remains the same. The mount root is the
27/// highest directory on the same device.
28///
29/// Note: this will not detect bind mounts of a subdirectory (where `st_dev`
30/// is the same all the way to `/`). This matches the C btrfs-progs behavior.
31fn find_mount_root(path: &Path) -> Result<PathBuf> {
32    use std::os::unix::fs::MetadataExt;
33
34    let path = path
35        .canonicalize()
36        .with_context(|| format!("cannot canonicalize '{}'", path.display()))?;
37    let dev = path
38        .metadata()
39        .with_context(|| format!("cannot stat '{}'", path.display()))?
40        .dev();
41
42    let mut root = path.clone();
43    while let Some(parent) = root.parent() {
44        let parent_dev = parent
45            .metadata()
46            .with_context(|| format!("cannot stat '{}'", parent.display()))?
47            .dev();
48        if parent_dev != dev {
49            break;
50        }
51        root = parent.to_path_buf();
52    }
53
54    Ok(root)
55}
56
57/// Applies a parsed btrfs send stream to a mounted btrfs filesystem.
58///
59/// `ReceiveContext` is the receive-side counterpart to the kernel's
60/// `BTRFS_IOC_SEND`. It takes [`StreamCommand`] values produced by
61/// [`StreamReader`][crate::StreamReader] and executes the corresponding
62/// filesystem operations to recreate the sent subvolume on the destination.
63///
64/// The typical usage pattern is:
65///
66/// 1. Create a context with [`ReceiveContext::new`], pointing at the
67///    destination directory (must be on a mounted btrfs filesystem).
68/// 2. Feed each command from the stream into [`process_command`][Self::process_command].
69/// 3. When the stream yields [`StreamCommand::End`], call
70///    [`finish_subvol`][Self::finish_subvol] to finalize the received
71///    subvolume (sets the received UUID and marks it read-only).
72/// 4. For multi-stream input, repeat from step 2 with a new stream reader.
73///
74/// Supported operations:
75///
76/// v1 commands: subvolume and snapshot creation, file/directory/symlink/fifo/
77/// socket/device node creation, rename, link, unlink, rmdir, write (with fd
78/// caching for sequential writes to the same file), clone range (resolves
79/// source subvolume via UUID tree lookup), xattr set/remove, truncate, chmod,
80/// chown, utimes. `UpdateExtent` is a no-op (informational only).
81///
82/// v2 commands: encoded write (passes compressed data directly to the kernel
83/// via `BTRFS_IOC_ENCODED_WRITE`, with automatic decompression fallback for
84/// zlib, zstd, and lzo when the ioctl is unavailable or fails), fallocate
85/// (preallocate and punch hole). Fileattr is intentionally a no-op, matching
86/// the C reference.
87///
88/// v3 commands: enable verity (`FS_IOC_ENABLE_VERITY`).
89///
90/// Snapshot creation resolves the parent subvolume by searching the UUID tree
91/// for the received UUID first, then falling back to the regular UUID. The
92/// parent's ctransid is verified against both ctransid and stransid to handle
93/// parents that were themselves received.
94///
95/// Requires `CAP_SYS_ADMIN` and a mounted, writable btrfs filesystem.
96pub struct ReceiveContext {
97    /// File descriptor to the mount root (for UUID tree searches and snapshots).
98    mnt_fd: File,
99    /// Absolute path of the filesystem mount root (for resolving subvolid paths).
100    mount_root: PathBuf,
101    /// Absolute path of the destination directory.
102    dest_dir: PathBuf,
103    /// Path of the current subvolume being received (relative name).
104    cur_subvol: Option<String>,
105    /// Absolute path of the current subvolume.
106    cur_subvol_path: Option<PathBuf>,
107    /// UUID from the stream's SUBVOL/SNAPSHOT command (for `SET_RECEIVED_SUBVOL`).
108    received_uuid: Option<uuid::Uuid>,
109    /// ctransid from the stream (for `SET_RECEIVED_SUBVOL`).
110    stransid: u64,
111    /// Cached write fd: keep one file open to avoid repeated open/close for
112    /// sequential writes to the same file.
113    write_fd: Option<File>,
114    write_path: PathBuf,
115}
116
117impl ReceiveContext {
118    /// Create a new receive context rooted at `dest_dir`.
119    ///
120    /// `dest_dir` must be a directory on a mounted btrfs filesystem. The mount
121    /// root is auto-detected by walking up the directory tree while the device
122    /// ID stays the same. An fd to the mount root is kept open for UUID tree
123    /// lookups; subvolumes are created under `dest_dir`.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the mount root cannot be determined or opened.
128    pub fn new(dest_dir: &Path) -> Result<Self> {
129        let mount_root = find_mount_root(dest_dir)?;
130        let mnt_fd = File::open(&mount_root).with_context(|| {
131            format!("cannot open mount root '{}'", mount_root.display())
132        })?;
133
134        Ok(Self {
135            mnt_fd,
136            mount_root,
137            dest_dir: dest_dir.to_path_buf(),
138            cur_subvol: None,
139            cur_subvol_path: None,
140            received_uuid: None,
141            stransid: 0,
142            write_fd: None,
143            write_path: PathBuf::new(),
144        })
145    }
146
147    /// Dispatch and execute a single stream command.
148    ///
149    /// The caller is responsible for handling [`StreamCommand::End`] before
150    /// calling this method (it will panic on `End`). All other command types
151    /// are dispatched to the appropriate handler. Paths in the command are
152    /// resolved relative to the current subvolume within the destination
153    /// directory.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the filesystem operation for the command fails.
158    #[allow(clippy::too_many_lines)]
159    pub fn process_command(&mut self, cmd: &StreamCommand) -> Result<()> {
160        match cmd {
161            StreamCommand::Subvol {
162                path,
163                uuid,
164                ctransid,
165            } => self.process_subvol(path, uuid, *ctransid),
166            StreamCommand::Snapshot {
167                path,
168                uuid,
169                ctransid,
170                clone_uuid,
171                clone_ctransid,
172            } => self.process_snapshot(
173                path,
174                uuid,
175                *ctransid,
176                clone_uuid,
177                *clone_ctransid,
178            ),
179            StreamCommand::Mkfile { path } => self.process_mkfile(path),
180            StreamCommand::Mkdir { path } => self.process_mkdir(path),
181            StreamCommand::Mknod { path, mode, rdev } => {
182                self.process_mknod(path, *mode, *rdev)
183            }
184            StreamCommand::Mkfifo { path } => self.process_mkfifo(path),
185            StreamCommand::Mksock { path } => self.process_mksock(path),
186            StreamCommand::Symlink { path, target } => {
187                self.process_symlink(path, target)
188            }
189            StreamCommand::Rename { from, to } => self.process_rename(from, to),
190            StreamCommand::Link { path, target } => {
191                self.process_link(path, target)
192            }
193            StreamCommand::Unlink { path } => self.process_unlink(path),
194            StreamCommand::Rmdir { path } => self.process_rmdir(path),
195            StreamCommand::Write { path, offset, data } => {
196                self.process_write(path, *offset, data)
197            }
198            StreamCommand::Clone {
199                path,
200                offset,
201                len,
202                clone_uuid,
203                clone_ctransid,
204                clone_path,
205                clone_offset,
206            } => self.process_clone(
207                path,
208                *offset,
209                *len,
210                clone_uuid,
211                *clone_ctransid,
212                clone_path,
213                *clone_offset,
214            ),
215            StreamCommand::SetXattr { path, name, data } => {
216                self.process_set_xattr(path, name, data)
217            }
218            StreamCommand::RemoveXattr { path, name } => {
219                self.process_remove_xattr(path, name)
220            }
221            StreamCommand::Truncate { path, size } => {
222                self.process_truncate(path, *size)
223            }
224            StreamCommand::Chmod { path, mode } => {
225                self.process_chmod(path, *mode)
226            }
227            StreamCommand::Chown { path, uid, gid } => {
228                self.process_chown(path, *uid, *gid)
229            }
230            StreamCommand::Utimes {
231                path, atime, mtime, ..
232            } => self.process_utimes(path, atime, mtime),
233            StreamCommand::UpdateExtent { .. } => Ok(()),
234            StreamCommand::EncodedWrite {
235                path,
236                offset,
237                unencoded_file_len,
238                unencoded_len,
239                unencoded_offset,
240                compression,
241                encryption,
242                data,
243            } => self.process_encoded_write(
244                path,
245                *offset,
246                *unencoded_file_len,
247                *unencoded_len,
248                *unencoded_offset,
249                *compression,
250                *encryption,
251                data,
252            ),
253            StreamCommand::Fallocate {
254                path,
255                mode,
256                offset,
257                len,
258            } => self.process_fallocate(path, *mode, *offset, *len),
259            StreamCommand::Fileattr { .. } => {
260                // Intentionally a no-op, matching C reference.  File
261                // attributes (chattr flags) are filesystem-internal and
262                // not reliably transferable across systems.
263                Ok(())
264            }
265            StreamCommand::EnableVerity {
266                path,
267                algorithm,
268                block_size,
269                salt,
270                sig,
271            } => self.process_enable_verity(
272                path,
273                *algorithm,
274                *block_size,
275                salt,
276                sig,
277            ),
278            StreamCommand::End => unreachable!("End is handled by the caller"),
279        }
280    }
281
282    /// Finalize the current subvolume after all commands have been applied.
283    ///
284    /// This sets the received UUID and stransid on the subvolume via
285    /// `BTRFS_IOC_SET_RECEIVED_SUBVOL`, then marks it read-only. Call this
286    /// after processing a [`StreamCommand::End`] or at EOF if a subvolume
287    /// was in progress. Safe to call when no subvolume is active (returns
288    /// `Ok(())` immediately).
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if setting the received UUID or read-only flag fails.
293    pub fn finish_subvol(&mut self) -> Result<()> {
294        self.close_write_fd();
295
296        let subvol_path = match &self.cur_subvol_path {
297            Some(p) => p.clone(),
298            None => return Ok(()),
299        };
300        let uuid = match &self.received_uuid {
301            Some(u) => *u,
302            None => return Ok(()),
303        };
304
305        let subvol_file = File::open(&subvol_path).with_context(|| {
306            format!("cannot open subvolume '{}'", subvol_path.display())
307        })?;
308        let fd = subvol_file.as_fd();
309
310        received_subvol_set(fd, &uuid, self.stransid).with_context(|| {
311            format!(
312                "failed to set received subvol on '{}'",
313                subvol_path.display()
314            )
315        })?;
316
317        // Make the subvolume read-only.
318        let flags = subvolume_flags_get(fd).with_context(|| {
319            format!("failed to get flags for '{}'", subvol_path.display())
320        })?;
321        subvolume_flags_set(fd, flags | SubvolumeFlags::RDONLY).with_context(
322            || {
323                format!(
324                    "failed to set read-only on '{}'",
325                    subvol_path.display()
326                )
327            },
328        )?;
329
330        self.cur_subvol = None;
331        self.cur_subvol_path = None;
332        self.received_uuid = None;
333        self.stransid = 0;
334
335        Ok(())
336    }
337
338    /// Close the cached write file descriptor, if any.
339    ///
340    /// Write operations cache an open fd to avoid repeated open/close when
341    /// the stream contains sequential writes to the same file. Call this
342    /// before operations that require no open writable fds (e.g. enabling
343    /// verity) or when switching subvolumes.
344    pub fn close_write_fd(&mut self) {
345        self.write_fd = None;
346        self.write_path = PathBuf::new();
347    }
348
349    fn full_path(&self, relative: &str) -> Result<PathBuf> {
350        let subvol_path = self
351            .cur_subvol_path
352            .as_ref()
353            .ok_or_else(|| anyhow!("no current subvolume"))?;
354        Ok(subvol_path.join(relative))
355    }
356
357    fn process_subvol(
358        &mut self,
359        path: &str,
360        uuid: &uuid::Uuid,
361        ctransid: u64,
362    ) -> Result<()> {
363        self.finish_subvol()?;
364
365        let subvol_path = self.dest_dir.join(path);
366
367        // Create subvolume using the parent directory fd.
368        let parent_dir = File::open(&self.dest_dir).with_context(|| {
369            format!("cannot open '{}'", self.dest_dir.display())
370        })?;
371        let c_name = CString::new(path).with_context(|| {
372            format!("subvolume name contains null byte: {path}")
373        })?;
374        subvolume_create(parent_dir.as_fd(), &c_name, &[]).with_context(
375            || {
376                format!(
377                    "failed to create subvolume '{}'",
378                    subvol_path.display()
379                )
380            },
381        )?;
382
383        self.cur_subvol = Some(path.to_string());
384        self.cur_subvol_path = Some(subvol_path);
385        self.received_uuid = Some(*uuid);
386        self.stransid = ctransid;
387
388        Ok(())
389    }
390
391    fn process_snapshot(
392        &mut self,
393        path: &str,
394        uuid: &uuid::Uuid,
395        ctransid: u64,
396        clone_uuid: &uuid::Uuid,
397        clone_ctransid: u64,
398    ) -> Result<()> {
399        self.finish_subvol()?;
400
401        let subvol_path = self.dest_dir.join(path);
402
403        // Find the parent subvolume by its received UUID, then fall back to UUID.
404        let parent_root_id = subvolume_search_by_received_uuid(
405            self.mnt_fd.as_fd(),
406            clone_uuid,
407        )
408        .or_else(|_| subvolume_search_by_uuid(self.mnt_fd.as_fd(), clone_uuid))
409        .with_context(|| {
410            format!(
411                "cannot find parent subvolume with UUID {} for snapshot '{}'",
412                clone_uuid.as_hyphenated(),
413                path
414            )
415        })?;
416
417        // Verify the parent's ctransid matches.
418        let parent_path = subvolid_resolve(self.mnt_fd.as_fd(), parent_root_id)
419            .with_context(|| {
420                format!(
421                    "cannot resolve path for parent subvolume {parent_root_id}"
422                )
423            })?;
424
425        // Open the parent subvolume to verify ctransid and create the snapshot.
426        // The path from subvolid_resolve is relative to the filesystem root,
427        // so join with mount_root, not dest_dir.
428        let parent_full = self.mount_root.join(&parent_path);
429        let parent_file = File::open(&parent_full).with_context(|| {
430            format!("cannot open parent subvolume '{}'", parent_full.display())
431        })?;
432
433        let parent_info =
434            subvolume_info(parent_file.as_fd()).with_context(|| {
435                format!(
436                    "failed to get info for parent '{}'",
437                    parent_full.display()
438                )
439            })?;
440
441        // The parent's ctransid must match: check both ctransid and stransid
442        // (stransid is set when the parent was itself received).
443        if parent_info.ctransid != clone_ctransid
444            && parent_info.stransid != clone_ctransid
445        {
446            bail!(
447                "parent subvolume '{}' ctransid mismatch: stream expects {}, found ctransid={} stransid={}",
448                parent_path,
449                clone_ctransid,
450                parent_info.ctransid,
451                parent_info.stransid,
452            );
453        }
454
455        // Create the snapshot.
456        let dest_dir_file = File::open(&self.dest_dir).with_context(|| {
457            format!("cannot open '{}'", self.dest_dir.display())
458        })?;
459        let c_name = CString::new(path).with_context(|| {
460            format!("snapshot name contains null byte: {path}")
461        })?;
462        snapshot_create(
463            dest_dir_file.as_fd(),
464            parent_file.as_fd(),
465            &c_name,
466            false,
467            &[],
468        )
469        .with_context(|| {
470            format!("failed to create snapshot '{}'", subvol_path.display())
471        })?;
472
473        // Make the snapshot writable so we can apply the stream delta.
474        let snap_file = File::open(&subvol_path).with_context(|| {
475            format!("cannot open snapshot '{}'", subvol_path.display())
476        })?;
477        let snap_flags =
478            subvolume_flags_get(snap_file.as_fd()).with_context(|| {
479                format!("failed to get flags for '{}'", subvol_path.display())
480            })?;
481        if snap_flags.contains(SubvolumeFlags::RDONLY) {
482            subvolume_flags_set(
483                snap_file.as_fd(),
484                snap_flags & !SubvolumeFlags::RDONLY,
485            )
486            .with_context(|| {
487                format!(
488                    "failed to make snapshot '{}' writable",
489                    subvol_path.display()
490                )
491            })?;
492        }
493
494        self.cur_subvol = Some(path.to_string());
495        self.cur_subvol_path = Some(subvol_path);
496        self.received_uuid = Some(*uuid);
497        self.stransid = ctransid;
498
499        Ok(())
500    }
501
502    fn process_mkfile(&mut self, path: &str) -> Result<()> {
503        let full = self.full_path(path)?;
504        File::create(&full).with_context(|| {
505            format!("failed to create file '{}'", full.display())
506        })?;
507        Ok(())
508    }
509
510    fn process_mkdir(&mut self, path: &str) -> Result<()> {
511        let full = self.full_path(path)?;
512        fs::create_dir(&full).with_context(|| {
513            format!("failed to create directory '{}'", full.display())
514        })?;
515        Ok(())
516    }
517
518    #[allow(clippy::cast_possible_truncation)] // mode/rdev fit in mode_t/dev_t
519    fn process_mknod(
520        &mut self,
521        path: &str,
522        mode: u64,
523        rdev: u64,
524    ) -> Result<()> {
525        self.do_mknod(path, mode as nix::libc::mode_t, rdev as nix::libc::dev_t)
526    }
527
528    fn process_mkfifo(&mut self, path: &str) -> Result<()> {
529        self.do_mknod(path, nix::libc::S_IFIFO | 0o600, 0)
530    }
531
532    fn process_mksock(&mut self, path: &str) -> Result<()> {
533        self.do_mknod(path, nix::libc::S_IFSOCK | 0o600, 0)
534    }
535
536    fn do_mknod(
537        &mut self,
538        path: &str,
539        mode: nix::libc::mode_t,
540        rdev: nix::libc::dev_t,
541    ) -> Result<()> {
542        let full = self.full_path(path)?;
543        let c_path = path_to_cstring(&full)?;
544        let ret = unsafe { nix::libc::mknod(c_path.as_ptr(), mode, rdev) };
545        if ret < 0 {
546            return Err(io::Error::last_os_error()).with_context(|| {
547                format!("mknod failed for '{}'", full.display())
548            });
549        }
550        Ok(())
551    }
552
553    fn process_symlink(&mut self, path: &str, target: &str) -> Result<()> {
554        let full = self.full_path(path)?;
555        std::os::unix::fs::symlink(target, &full).with_context(|| {
556            format!("failed to create symlink '{}'", full.display())
557        })?;
558        Ok(())
559    }
560
561    fn process_rename(&mut self, from: &str, to: &str) -> Result<()> {
562        let full_from = self.full_path(from)?;
563        let full_to = self.full_path(to)?;
564        fs::rename(&full_from, &full_to).with_context(|| {
565            format!(
566                "failed to rename '{}' to '{}'",
567                full_from.display(),
568                full_to.display()
569            )
570        })?;
571        Ok(())
572    }
573
574    fn process_link(&mut self, path: &str, target: &str) -> Result<()> {
575        let full_path = self.full_path(path)?;
576        let full_target = self.full_path(target)?;
577        fs::hard_link(&full_target, &full_path).with_context(|| {
578            format!(
579                "failed to hard link '{}' -> '{}'",
580                full_path.display(),
581                full_target.display()
582            )
583        })?;
584        Ok(())
585    }
586
587    fn process_unlink(&mut self, path: &str) -> Result<()> {
588        let full = self.full_path(path)?;
589        // Close cached write fd if it points to this file.
590        if self.write_path == full {
591            self.close_write_fd();
592        }
593        fs::remove_file(&full).with_context(|| {
594            format!("failed to unlink '{}'", full.display())
595        })?;
596        Ok(())
597    }
598
599    fn process_rmdir(&mut self, path: &str) -> Result<()> {
600        let full = self.full_path(path)?;
601        fs::remove_dir(&full)
602            .with_context(|| format!("failed to rmdir '{}'", full.display()))?;
603        Ok(())
604    }
605
606    fn process_write(
607        &mut self,
608        path: &str,
609        offset: u64,
610        data: &[u8],
611    ) -> Result<()> {
612        let full = self.full_path(path)?;
613
614        // Reuse cached fd if writing to the same file.
615        if self.write_path != full {
616            self.close_write_fd();
617            let file =
618                OpenOptions::new().write(true).open(&full).with_context(
619                    || format!("cannot open '{}' for writing", full.display()),
620                )?;
621            self.write_fd = Some(file);
622            self.write_path.clone_from(&full);
623        }
624
625        let fd = self.write_fd.as_ref().unwrap();
626        #[allow(clippy::cast_possible_wrap)] // offset fits in off_t
627        let written = unsafe {
628            nix::libc::pwrite(
629                fd.as_raw_fd(),
630                data.as_ptr().cast::<nix::libc::c_void>(),
631                data.len(),
632                offset as nix::libc::off_t,
633            )
634        };
635        if written < 0 {
636            return Err(io::Error::last_os_error()).with_context(|| {
637                format!("pwrite failed on '{}'", full.display())
638            });
639        }
640        #[allow(clippy::cast_sign_loss)] // checked non-negative above
641        if (written as usize) != data.len() {
642            bail!(
643                "short pwrite on '{}': wrote {} of {} bytes",
644                full.display(),
645                written,
646                data.len()
647            );
648        }
649
650        Ok(())
651    }
652
653    #[allow(clippy::too_many_arguments)]
654    fn process_clone(
655        &mut self,
656        path: &str,
657        offset: u64,
658        len: u64,
659        clone_uuid: &uuid::Uuid,
660        _clone_ctransid: u64,
661        clone_path: &str,
662        clone_offset: u64,
663    ) -> Result<()> {
664        let full = self.full_path(path)?;
665
666        // Find the clone source subvolume.
667        let clone_subvol_root =
668            subvolume_search_by_received_uuid(self.mnt_fd.as_fd(), clone_uuid)
669                .or_else(|_| {
670                    subvolume_search_by_uuid(self.mnt_fd.as_fd(), clone_uuid)
671                })
672                .with_context(|| {
673                    format!(
674                        "cannot find clone source subvolume with UUID {}",
675                        clone_uuid.as_hyphenated()
676                    )
677                })?;
678
679        let subvol_path =
680            subvolid_resolve(self.mnt_fd.as_fd(), clone_subvol_root)
681                .with_context(|| {
682                    format!("cannot resolve path for clone source subvolume {clone_subvol_root}")
683                })?;
684
685        // The path from subvolid_resolve is relative to the filesystem root,
686        // so join with mount_root, not dest_dir.
687        let clone_full = self.mount_root.join(&subvol_path).join(clone_path);
688        let clone_file = File::open(&clone_full).with_context(|| {
689            format!("cannot open clone source '{}'", clone_full.display())
690        })?;
691
692        // Close cached write fd if it's for this file — we need a fresh fd.
693        if self.write_path == full {
694            self.close_write_fd();
695        }
696
697        let dest_file = OpenOptions::new()
698            .write(true)
699            .open(&full)
700            .with_context(|| {
701                format!("cannot open '{}' for clone", full.display())
702            })?;
703
704        clone_range(
705            dest_file.as_fd(),
706            clone_file.as_fd(),
707            clone_offset,
708            len,
709            offset,
710        )
711        .with_context(|| {
712            format!("clone_range failed on '{}'", full.display())
713        })?;
714
715        Ok(())
716    }
717
718    fn process_set_xattr(
719        &mut self,
720        path: &str,
721        name: &str,
722        data: &[u8],
723    ) -> Result<()> {
724        let full = self.full_path(path)?;
725        let c_path = path_to_cstring(&full)?;
726        let c_name = CString::new(name)
727            .with_context(|| format!("invalid xattr name: {name}"))?;
728        let ret = unsafe {
729            nix::libc::lsetxattr(
730                c_path.as_ptr(),
731                c_name.as_ptr(),
732                data.as_ptr().cast::<nix::libc::c_void>(),
733                data.len(),
734                0,
735            )
736        };
737        if ret < 0 {
738            return Err(io::Error::last_os_error()).with_context(|| {
739                format!("lsetxattr failed on '{}'", full.display())
740            });
741        }
742        Ok(())
743    }
744
745    fn process_remove_xattr(&mut self, path: &str, name: &str) -> Result<()> {
746        let full = self.full_path(path)?;
747        let c_path = path_to_cstring(&full)?;
748        let c_name = CString::new(name)
749            .with_context(|| format!("invalid xattr name: {name}"))?;
750        let ret = unsafe {
751            nix::libc::lremovexattr(c_path.as_ptr(), c_name.as_ptr())
752        };
753        if ret < 0 {
754            return Err(io::Error::last_os_error()).with_context(|| {
755                format!("lremovexattr failed on '{}'", full.display())
756            });
757        }
758        Ok(())
759    }
760
761    #[allow(clippy::cast_possible_wrap)] // size fits in off_t
762    fn process_truncate(&mut self, path: &str, size: u64) -> Result<()> {
763        let full = self.full_path(path)?;
764        let c_path = path_to_cstring(&full)?;
765        let ret = unsafe {
766            nix::libc::truncate(c_path.as_ptr(), size as nix::libc::off_t)
767        };
768        if ret < 0 {
769            return Err(io::Error::last_os_error()).with_context(|| {
770                format!("truncate failed on '{}'", full.display())
771            });
772        }
773        Ok(())
774    }
775
776    #[allow(clippy::cast_possible_truncation)] // mode fits in u32
777    fn process_chmod(&mut self, path: &str, mode: u64) -> Result<()> {
778        let full = self.full_path(path)?;
779        fs::set_permissions(&full, fs::Permissions::from_mode(mode as u32))
780            .with_context(|| format!("chmod failed on '{}'", full.display()))?;
781        Ok(())
782    }
783
784    #[allow(clippy::cast_possible_truncation)] // uid/gid fit in uid_t/gid_t
785    fn process_chown(&mut self, path: &str, uid: u64, gid: u64) -> Result<()> {
786        let full = self.full_path(path)?;
787        let c_path = path_to_cstring(&full)?;
788        let ret = unsafe {
789            nix::libc::lchown(
790                c_path.as_ptr(),
791                uid as nix::libc::uid_t,
792                gid as nix::libc::gid_t,
793            )
794        };
795        if ret < 0 {
796            return Err(io::Error::last_os_error()).with_context(|| {
797                format!("lchown failed on '{}'", full.display())
798            });
799        }
800        Ok(())
801    }
802
803    #[allow(clippy::cast_possible_wrap)] // timestamps fit in signed fields
804    fn process_utimes(
805        &mut self,
806        path: &str,
807        atime: &Timespec,
808        mtime: &Timespec,
809    ) -> Result<()> {
810        let full = self.full_path(path)?;
811        let c_path = path_to_cstring(&full)?;
812        let times = [
813            nix::libc::timespec {
814                tv_sec: atime.sec as i64,
815                tv_nsec: i64::from(atime.nsec),
816            },
817            nix::libc::timespec {
818                tv_sec: mtime.sec as i64,
819                tv_nsec: i64::from(mtime.nsec),
820            },
821        ];
822        let ret = unsafe {
823            nix::libc::utimensat(
824                nix::libc::AT_FDCWD,
825                c_path.as_ptr(),
826                times.as_ptr(),
827                nix::libc::AT_SYMLINK_NOFOLLOW,
828            )
829        };
830        if ret < 0 {
831            return Err(io::Error::last_os_error()).with_context(|| {
832                format!("utimensat failed on '{}'", full.display())
833            });
834        }
835        Ok(())
836    }
837
838    #[allow(clippy::cast_possible_wrap)] // mode/offset/len fit in signed types
839    fn process_fallocate(
840        &mut self,
841        path: &str,
842        mode: u32,
843        offset: u64,
844        len: u64,
845    ) -> Result<()> {
846        let full = self.full_path(path)?;
847        self.close_write_fd();
848        let file =
849            OpenOptions::new()
850                .write(true)
851                .open(&full)
852                .with_context(|| {
853                    format!("cannot open '{}' for fallocate", full.display())
854                })?;
855        let ret = unsafe {
856            nix::libc::fallocate(
857                file.as_raw_fd(),
858                mode as i32,
859                offset as nix::libc::off_t,
860                len as nix::libc::off_t,
861            )
862        };
863        if ret < 0 {
864            return Err(io::Error::last_os_error()).with_context(|| {
865                format!("fallocate failed on '{}'", full.display())
866            });
867        }
868        Ok(())
869    }
870
871    fn process_enable_verity(
872        &mut self,
873        path: &str,
874        algorithm: u8,
875        block_size: u32,
876        salt: &[u8],
877        sig: &[u8],
878    ) -> Result<()> {
879        let full = self.full_path(path)?;
880
881        // Must close any cached write fd first: enabling verity requires no
882        // open writable file descriptors.
883        self.close_write_fd();
884
885        // fs-verity requires the file to be opened read-only.
886        let file = File::open(&full).with_context(|| {
887            format!("cannot open '{}' for verity", full.display())
888        })?;
889
890        crate::verity::enable_verity(
891            file.as_fd(),
892            algorithm,
893            block_size,
894            salt,
895            sig,
896        )
897        .with_context(|| {
898            format!("FS_IOC_ENABLE_VERITY failed on '{}'", full.display())
899        })?;
900
901        Ok(())
902    }
903
904    #[allow(clippy::too_many_arguments)]
905    fn process_encoded_write(
906        &mut self,
907        path: &str,
908        offset: u64,
909        unencoded_file_len: u64,
910        unencoded_len: u64,
911        unencoded_offset: u64,
912        compression: u32,
913        encryption: u32,
914        data: &[u8],
915    ) -> Result<()> {
916        use std::os::unix::fs::FileExt;
917
918        if encryption != 0 {
919            bail!(
920                "encrypted encoded writes are not supported (encryption={encryption})"
921            );
922        }
923
924        let full = self.full_path(path)?;
925
926        // Reuse cached fd if writing to the same file.
927        if self.write_path != full {
928            self.close_write_fd();
929            let file =
930                OpenOptions::new().write(true).open(&full).with_context(
931                    || format!("cannot open '{}' for writing", full.display()),
932                )?;
933            self.write_fd = Some(file);
934            self.write_path.clone_from(&full);
935        }
936
937        // Try the encoded write ioctl first — passes compressed data directly
938        // to the filesystem without decompression.
939        let fd = self.write_fd.as_ref().unwrap();
940        match encoded_write(
941            fd.as_fd(),
942            data,
943            offset,
944            unencoded_file_len,
945            unencoded_len,
946            unencoded_offset,
947            compression,
948            encryption,
949        ) {
950            Ok(()) => return Ok(()),
951            Err(
952                nix::errno::Errno::ENOTTY
953                | nix::errno::Errno::EINVAL
954                | nix::errno::Errno::ENOSPC,
955            ) => {
956                // Fall through to decompression.
957            }
958            Err(e) => {
959                return Err(e).with_context(|| {
960                    format!("encoded write failed on '{}'", full.display())
961                });
962            }
963        }
964
965        // Decompression fallback: decompress and pwrite.
966        #[allow(clippy::cast_possible_truncation)] // lengths fit in usize
967        let decompressed =
968            decompress(data, unencoded_len as usize, compression)
969                .with_context(|| {
970                    format!("decompression failed for '{}'", full.display())
971                })?;
972
973        #[allow(clippy::cast_possible_truncation)]
974        // offsets/lengths fit in usize
975        let write_data = &decompressed[unencoded_offset as usize
976            ..unencoded_offset as usize + unencoded_file_len as usize];
977
978        fd.write_all_at(write_data, offset).with_context(|| {
979            format!("pwrite failed on '{}'", full.display())
980        })?;
981
982        Ok(())
983    }
984}
985
986/// Decompress `data` into a buffer of `output_len` bytes using the specified
987/// compression algorithm.
988fn decompress(
989    data: &[u8],
990    output_len: usize,
991    compression: u32,
992) -> Result<Vec<u8>> {
993    match compression {
994        0 => {
995            // No compression — data is already unencoded.
996            Ok(data.to_vec())
997        }
998        1 => {
999            // ZLIB
1000            use std::io::Read;
1001            let mut decoder = flate2::read::ZlibDecoder::new(data);
1002            let mut out = vec![0u8; output_len];
1003            decoder
1004                .read_exact(&mut out)
1005                .context("zlib decompression failed")?;
1006            Ok(out)
1007        }
1008        2 => {
1009            // ZSTD
1010            let out = zstd::bulk::decompress(data, output_len)
1011                .context("zstd decompression failed")?;
1012            Ok(out)
1013        }
1014        3..=7 => {
1015            // LZO with sector sizes 4K through 64K.
1016            // Sector size = 1 << (compression - 3 + 12).
1017            let sector_size = 1usize << (compression - 3 + 12);
1018            decompress_lzo(data, output_len, sector_size)
1019        }
1020        _ => bail!("unsupported compression type {compression}"),
1021    }
1022}
1023
1024/// Decompress btrfs LZO format: data is compressed sector by sector. Each
1025/// sector is independently LZO1X compressed. The format is:
1026/// - 4 bytes LE: total compressed size (including this field)
1027/// - For each sector:
1028///   - 4 bytes LE: compressed segment length
1029///   - N bytes: LZO1X compressed data
1030///   - Padding to the next sector boundary (if the remaining space in
1031///     the current sector is less than 4 bytes for the next header)
1032fn decompress_lzo(
1033    data: &[u8],
1034    output_len: usize,
1035    sector_size: usize,
1036) -> Result<Vec<u8>> {
1037    if data.len() < 4 {
1038        bail!("LZO data too short for header");
1039    }
1040    let total_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
1041    if total_len > data.len() {
1042        bail!(
1043            "LZO total length {total_len} exceeds data length {}",
1044            data.len()
1045        );
1046    }
1047
1048    let mut out = Vec::with_capacity(output_len);
1049    let mut pos = 4; // skip the 4-byte total length header
1050
1051    while pos < total_len && out.len() < output_len {
1052        // Skip to the next sector boundary if the remaining space in the
1053        // current sector is too small for a segment header (4 bytes).
1054        let sector_remaining = sector_size - (pos % sector_size);
1055        if sector_remaining < 4 {
1056            if total_len - pos <= sector_remaining {
1057                break;
1058            }
1059            pos += sector_remaining;
1060        }
1061
1062        if pos + 4 > total_len {
1063            bail!("LZO segment header truncated at offset {pos}");
1064        }
1065        let seg_len =
1066            u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
1067        pos += 4;
1068
1069        if pos + seg_len > data.len() {
1070            bail!(
1071                "LZO segment data truncated at offset {pos}, need {seg_len} bytes"
1072            );
1073        }
1074
1075        let remaining = (output_len - out.len()).min(sector_size);
1076        let mut segment_out = vec![0u8; remaining];
1077        lzokay::decompress::decompress(
1078            &data[pos..pos + seg_len],
1079            &mut segment_out,
1080        )
1081        .map_err(|e| {
1082            anyhow!("LZO decompression failed at offset {pos}: {e:?}")
1083        })?;
1084        out.extend_from_slice(&segment_out);
1085
1086        pos += seg_len;
1087    }
1088
1089    if out.len() < output_len {
1090        out.resize(output_len, 0);
1091    }
1092
1093    Ok(out)
1094}
1095
1096fn path_to_cstring(path: &Path) -> Result<CString> {
1097    use std::os::unix::ffi::OsStrExt;
1098    CString::new(path.as_os_str().as_bytes()).with_context(|| {
1099        format!("path contains null byte: '{}'", path.display())
1100    })
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106    use std::path::Path;
1107
1108    // -- decompress: compression type 0 (none) --
1109
1110    #[test]
1111    fn decompress_none_passthrough() {
1112        let data = b"hello world";
1113        let result = decompress(data, data.len(), 0).unwrap();
1114        assert_eq!(result, data);
1115    }
1116
1117    // -- decompress: compression type 1 (zlib) --
1118
1119    #[test]
1120    fn decompress_zlib() {
1121        use flate2::write::ZlibEncoder;
1122        use std::io::Write;
1123
1124        let original = b"the quick brown fox jumps over the lazy dog";
1125        let mut encoder =
1126            ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1127        encoder.write_all(original).unwrap();
1128        let compressed = encoder.finish().unwrap();
1129
1130        let result = decompress(&compressed, original.len(), 1).unwrap();
1131        assert_eq!(result, original);
1132    }
1133
1134    // -- decompress: compression type 2 (zstd) --
1135
1136    #[test]
1137    fn decompress_zstd() {
1138        let original = b"repeating data repeating data repeating data";
1139        let compressed = zstd::bulk::compress(original, 3).unwrap();
1140
1141        let result = decompress(&compressed, original.len(), 2).unwrap();
1142        assert_eq!(result, original);
1143    }
1144
1145    // -- decompress: unsupported compression type --
1146
1147    #[test]
1148    fn decompress_unsupported_type() {
1149        let err = decompress(b"data", 4, 99).unwrap_err();
1150        assert!(
1151            err.to_string().contains("unsupported compression type 99"),
1152            "unexpected error: {err}"
1153        );
1154    }
1155
1156    // -- decompress_lzo: header too short --
1157
1158    #[test]
1159    fn decompress_lzo_header_too_short() {
1160        let err = decompress_lzo(&[0, 1, 2], 100, 4096).unwrap_err();
1161        assert!(
1162            err.to_string().contains("too short"),
1163            "unexpected error: {err}"
1164        );
1165    }
1166
1167    // -- decompress_lzo: total_len exceeds data --
1168
1169    #[test]
1170    fn decompress_lzo_total_len_exceeds_data() {
1171        // total_len = 1000, but data is only 8 bytes
1172        let mut data = vec![0u8; 8];
1173        data[0..4].copy_from_slice(&1000u32.to_le_bytes());
1174        let err = decompress_lzo(&data, 100, 4096).unwrap_err();
1175        assert!(
1176            err.to_string().contains("exceeds data length"),
1177            "unexpected error: {err}"
1178        );
1179    }
1180
1181    /// Build a btrfs-format LZO compressed buffer from raw segments.
1182    /// Each segment is LZO1X compressed data for one sector.
1183    fn build_lzo_buffer(segments: &[Vec<u8>], sector_size: usize) -> Vec<u8> {
1184        let mut buf = Vec::new();
1185        // Placeholder for total_len header
1186        buf.extend_from_slice(&[0u8; 4]);
1187
1188        for seg in segments {
1189            // 4-byte segment length
1190            buf.extend_from_slice(&(seg.len() as u32).to_le_bytes());
1191            buf.extend_from_slice(seg);
1192            // Pad to next sector boundary if needed (relative to start of
1193            // segment data, which begins at offset 4 in the overall buffer).
1194            // The position within the buffer after writing this segment:
1195            let pos = buf.len();
1196            let sector_rem = sector_size - (pos % sector_size);
1197            if sector_rem < 4 && sector_rem < sector_size {
1198                // Pad so next segment header is aligned
1199                buf.resize(buf.len() + sector_rem, 0);
1200            }
1201        }
1202
1203        // Write total_len (includes the 4-byte header itself)
1204        let total = buf.len() as u32;
1205        buf[0..4].copy_from_slice(&total.to_le_bytes());
1206        buf
1207    }
1208
1209    // -- decompress_lzo: single segment --
1210
1211    #[test]
1212    fn decompress_lzo_single_segment() {
1213        let original = b"hello lzo compression test data!";
1214        let compressed =
1215            lzo1x::compress(original, lzo1x::CompressLevel::default());
1216
1217        let buf = build_lzo_buffer(&[compressed], 4096);
1218        let result = decompress_lzo(&buf, original.len(), 4096).unwrap();
1219        assert_eq!(&result[..original.len()], original.as_slice());
1220    }
1221
1222    // -- decompress_lzo: output zero-fill --
1223    // When the decompressed data from all segments is shorter than output_len,
1224    // the remainder is zero-filled. We use a 4096-byte sector whose decompressed
1225    // content is exactly 4096 bytes, then request output_len = 8192 so the
1226    // second half is zeros.
1227
1228    #[test]
1229    fn decompress_lzo_output_zero_fill() {
1230        // Create exactly one sector worth of data (4096 bytes)
1231        let original = vec![0xABu8; 4096];
1232        let compressed =
1233            lzo1x::compress(&original, lzo1x::CompressLevel::default());
1234
1235        let output_len = 8192; // twice the data we have
1236        let buf = build_lzo_buffer(&[compressed], 4096);
1237        let result = decompress_lzo(&buf, output_len, 4096).unwrap();
1238        assert_eq!(&result[..4096], original.as_slice());
1239        // Remainder should be zero-filled
1240        assert!(
1241            result[4096..].iter().all(|&b| b == 0),
1242            "expected zero-fill after decompressed data"
1243        );
1244        assert_eq!(result.len(), output_len);
1245    }
1246
1247    // -- decompress_lzo: segment data truncated --
1248
1249    #[test]
1250    fn decompress_lzo_segment_truncated() {
1251        // total_len header says 20 bytes total, segment header says 100
1252        // bytes but only a few remain
1253        let mut data = vec![0u8; 20];
1254        let total_len: u32 = 20;
1255        data[0..4].copy_from_slice(&total_len.to_le_bytes());
1256        // Segment header at offset 4: claims 100 bytes of compressed data
1257        let seg_len: u32 = 100;
1258        data[4..8].copy_from_slice(&seg_len.to_le_bytes());
1259        // Only 12 bytes remain (offsets 8..20), far less than 100
1260
1261        let err = decompress_lzo(&data, 4096, 4096).unwrap_err();
1262        assert!(
1263            err.to_string().contains("truncated"),
1264            "unexpected error: {err}"
1265        );
1266    }
1267
1268    // -- decompress: LZO via compression types 3-7 --
1269
1270    #[test]
1271    fn decompress_lzo_via_compression_type_3() {
1272        let original = b"lzo via decompress entry point";
1273        let compressed =
1274            lzo1x::compress(original, lzo1x::CompressLevel::default());
1275        let buf = build_lzo_buffer(&[compressed], 4096);
1276
1277        // compression=3 means sector_size = 1 << (3-3+12) = 4096
1278        let result = decompress(&buf, original.len(), 3).unwrap();
1279        assert_eq!(&result[..original.len()], original.as_slice());
1280    }
1281
1282    // -- path_to_cstring --
1283
1284    #[test]
1285    fn path_to_cstring_valid() {
1286        let path = Path::new("/tmp/test-file");
1287        let cstr = path_to_cstring(path).unwrap();
1288        assert_eq!(cstr.as_bytes(), b"/tmp/test-file");
1289    }
1290
1291    #[test]
1292    fn path_to_cstring_with_null_byte() {
1293        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
1294        let os_str = OsStr::from_bytes(b"/tmp/bad\x00path");
1295        let path = Path::new(os_str);
1296        let err = path_to_cstring(path).unwrap_err();
1297        assert!(
1298            err.to_string().contains("null byte"),
1299            "unexpected error: {err}"
1300        );
1301    }
1302}