Skip to main content

btrfs_cli/subvolume/
delete.rs

1use crate::{RunContext, Runnable};
2use anyhow::{Context, Result, bail};
3use btrfs_uapi::{
4    filesystem::{start_sync, wait_sync},
5    subvolume::{
6        subvolume_delete, subvolume_delete_by_id, subvolume_info,
7        subvolume_list,
8    },
9};
10use clap::Parser;
11use std::{ffi::CString, fs::File, os::unix::io::AsFd, path::PathBuf};
12
13/// Delete one or more subvolumes or snapshots.
14///
15/// Delete subvolumes from the filesystem, specified by path or id. The
16/// corresponding directory is removed instantly but the data blocks are
17/// removed later.
18///
19/// The deletion does not involve full commit by default due to performance
20/// reasons (as a consequence, the subvolume may appear again after a crash).
21/// Use one of the --commit options to wait until the operation is safely
22/// stored on the media.
23#[derive(Parser, Debug)]
24pub struct SubvolumeDeleteCommand {
25    /// Wait for transaction commit at the end of the operation
26    #[clap(short = 'c', long, conflicts_with = "commit_each")]
27    pub commit_after: bool,
28
29    /// Wait for transaction commit after deleting each subvolume
30    #[clap(short = 'C', long, conflicts_with = "commit_after")]
31    pub commit_each: bool,
32
33    /// Delete by subvolume ID instead of path. When used, exactly one
34    /// positional argument is expected: the filesystem path (mount point).
35    #[clap(short = 'i', long, conflicts_with = "recursive")]
36    pub subvolid: Option<u64>,
37
38    /// Delete accessible subvolumes beneath each subvolume recursively.
39    /// This is not atomic and may need root to delete subvolumes not
40    /// accessible by the user.
41    #[clap(short = 'R', long, conflicts_with = "subvolid")]
42    pub recursive: bool,
43
44    /// Subvolume paths to delete, or (with --subvolid) the filesystem path
45    #[clap(required = true)]
46    pub paths: Vec<PathBuf>,
47}
48
49impl Runnable for SubvolumeDeleteCommand {
50    fn supports_dry_run(&self) -> bool {
51        true
52    }
53
54    fn run(&self, ctx: &RunContext) -> Result<()> {
55        if self.subvolid.is_some() && self.paths.len() != 1 {
56            bail!(
57                "--subvolid requires exactly one path argument (the filesystem mount point)"
58            );
59        }
60
61        let mut had_error = false;
62        // For --commit-after, we save an fd and sync once at the end.
63        let mut commit_after_fd: Option<File> = None;
64
65        if let Some(subvolid) = self.subvolid {
66            let (ok, fd) =
67                self.delete_by_id(subvolid, &self.paths[0], ctx.dry_run);
68            had_error |= !ok;
69            if self.commit_after {
70                commit_after_fd = fd;
71            }
72        } else {
73            for path in &self.paths {
74                let (ok, fd) = self.delete_by_path(path, ctx.dry_run);
75                had_error |= !ok;
76                if self.commit_after && fd.is_some() {
77                    commit_after_fd = fd;
78                }
79            }
80        }
81
82        // --commit-after: sync once at the end.
83        if !ctx.dry_run
84            && let Some(ref file) = commit_after_fd
85            && let Err(e) = wait_for_commit(file.as_fd())
86        {
87            eprintln!("error: failed to commit: {e:#}");
88            had_error = true;
89        }
90
91        if had_error {
92            bail!("one or more subvolumes could not be deleted");
93        }
94
95        Ok(())
96    }
97}
98
99impl SubvolumeDeleteCommand {
100    /// Delete a subvolume by path. Returns (success, optional fd for commit-after).
101    fn delete_by_path(
102        &self,
103        path: &PathBuf,
104        dry_run: bool,
105    ) -> (bool, Option<File>) {
106        let result = (|| -> Result<File> {
107            let parent = path.parent().ok_or_else(|| {
108                anyhow::anyhow!("'{}' has no parent directory", path.display())
109            })?;
110
111            let name_os = path.file_name().ok_or_else(|| {
112                anyhow::anyhow!("'{}' has no file name", path.display())
113            })?;
114
115            let name_str = name_os.to_str().ok_or_else(|| {
116                anyhow::anyhow!("'{}' is not valid UTF-8", path.display())
117            })?;
118
119            let cname = CString::new(name_str).with_context(|| {
120                format!("subvolume name contains a null byte: '{name_str}'")
121            })?;
122
123            let parent_file = File::open(parent).with_context(|| {
124                format!("failed to open '{}'", parent.display())
125            })?;
126            let fd = parent_file.as_fd();
127
128            if self.recursive && !dry_run {
129                self.delete_children(path)?;
130            }
131
132            println!("Delete subvolume '{}'", path.display());
133
134            if dry_run {
135                return Ok(parent_file);
136            }
137
138            subvolume_delete(fd, &cname).with_context(|| {
139                format!("failed to delete '{}'", path.display())
140            })?;
141
142            if self.commit_each {
143                wait_for_commit(fd).with_context(|| {
144                    format!("failed to commit after '{}'", path.display())
145                })?;
146            }
147
148            Ok(parent_file)
149        })();
150
151        match result {
152            Ok(file) => (true, Some(file)),
153            Err(e) => {
154                eprintln!("error: {e:#}");
155                (false, None)
156            }
157        }
158    }
159
160    /// Delete a subvolume by numeric ID. Returns (success, optional fd for commit-after).
161    fn delete_by_id(
162        &self,
163        subvolid: u64,
164        fs_path: &PathBuf,
165        dry_run: bool,
166    ) -> (bool, Option<File>) {
167        let result = (|| -> Result<File> {
168            let file = File::open(fs_path).with_context(|| {
169                format!("failed to open '{}'", fs_path.display())
170            })?;
171            let fd = file.as_fd();
172
173            println!("Delete subvolume (subvolid={subvolid})");
174
175            if dry_run {
176                return Ok(file);
177            }
178
179            subvolume_delete_by_id(fd, subvolid).with_context(|| {
180                format!(
181                    "failed to delete subvolid={subvolid} on '{}'",
182                    fs_path.display()
183                )
184            })?;
185
186            if self.commit_each {
187                wait_for_commit(fd).with_context(|| {
188                    format!("failed to commit on '{}'", fs_path.display())
189                })?;
190            }
191
192            Ok(file)
193        })();
194
195        match result {
196            Ok(file) => (true, Some(file)),
197            Err(e) => {
198                eprintln!("error: {e:#}");
199                (false, None)
200            }
201        }
202    }
203
204    /// Recursively delete all child subvolumes beneath `path`, deepest first.
205    fn delete_children(&self, path: &PathBuf) -> Result<()> {
206        let file = File::open(path)
207            .with_context(|| format!("failed to open '{}'", path.display()))?;
208        let fd = file.as_fd();
209
210        // Get the root ID of this subvolume.
211        let info = subvolume_info(fd).with_context(|| {
212            format!("failed to get subvolume info for '{}'", path.display())
213        })?;
214        let target_id = info.id;
215
216        // List all subvolumes on this filesystem and find those nested under target_id.
217        let all = subvolume_list(fd).with_context(|| {
218            format!("failed to list subvolumes on '{}'", path.display())
219        })?;
220
221        // Build the set of subvolume IDs that are descendants of target_id.
222        // We need post-order traversal (delete children before parents).
223        let mut children: Vec<u64> = Vec::new();
224        let mut frontier = vec![target_id];
225
226        while let Some(parent) = frontier.pop() {
227            for item in &all {
228                if item.parent_id == parent && item.root_id != target_id {
229                    children.push(item.root_id);
230                    frontier.push(item.root_id);
231                }
232            }
233        }
234
235        // Reverse so deepest children are deleted first (post-order).
236        children.reverse();
237
238        for child_id in children {
239            if let Some(item) = all.iter().find(|i| i.root_id == child_id) {
240                if item.name.is_empty() {
241                    log::info!("Delete subvolume (subvolid={child_id})");
242                } else {
243                    log::info!(
244                        "Delete subvolume '{}/{}'",
245                        path.display(),
246                        item.name
247                    );
248                }
249            }
250
251            subvolume_delete_by_id(fd, child_id).with_context(|| {
252                format!(
253                    "failed to delete child subvolid={child_id} under '{}'",
254                    path.display()
255                )
256            })?;
257
258            if self.commit_each {
259                wait_for_commit(fd).with_context(|| {
260                    format!("failed to commit after child subvolid={child_id}")
261                })?;
262            }
263        }
264
265        Ok(())
266    }
267}
268
269/// Initiate a sync and wait for it to complete (`start_sync` + `wait_sync`).
270fn wait_for_commit(fd: std::os::unix::io::BorrowedFd) -> Result<()> {
271    let transid = start_sync(fd).context("start_sync failed")?;
272    wait_sync(fd, transid).context("wait_sync failed")?;
273    Ok(())
274}