1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::unix::fs::PermissionsExt;
4use tracing::instrument;
5
6use crate::progress;
7
8#[derive(Debug, thiserror::Error)]
19#[error("{source:#}")]
20pub struct Error {
21 #[source]
22 pub source: anyhow::Error,
23 pub summary: Summary,
24}
25
26impl Error {
27 fn new(source: anyhow::Error, summary: Summary) -> Self {
28 Error { source, summary }
29 }
30}
31
32#[derive(Debug, Clone)]
33pub struct Settings {
34 pub fail_early: bool,
35}
36
37#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
38pub struct Summary {
39 pub files_removed: usize,
40 pub symlinks_removed: usize,
41 pub directories_removed: usize,
42}
43
44impl std::ops::Add for Summary {
45 type Output = Self;
46 fn add(self, other: Self) -> Self {
47 Self {
48 files_removed: self.files_removed + other.files_removed,
49 symlinks_removed: self.symlinks_removed + other.symlinks_removed,
50 directories_removed: self.directories_removed + other.directories_removed,
51 }
52 }
53}
54
55impl std::fmt::Display for Summary {
56 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
57 write!(
58 f,
59 "files removed: {}\n\
60 symlinks removed: {}\n\
61 directories removed: {}\n",
62 self.files_removed, self.symlinks_removed, self.directories_removed
63 )
64 }
65}
66
67#[instrument(skip(prog_track))]
68#[async_recursion]
69pub async fn rm(
70 prog_track: &'static progress::Progress,
71 path: &std::path::Path,
72 settings: &Settings,
73) -> Result<Summary, Error> {
74 let _ops_guard = prog_track.ops.guard();
75 tracing::debug!("read path metadata");
76 let src_metadata = tokio::fs::symlink_metadata(path)
77 .await
78 .with_context(|| format!("failed reading metadata from {:?}", &path))
79 .map_err(|err| Error::new(err, Default::default()))?;
80 if !src_metadata.is_dir() {
81 tracing::debug!("not a directory, just remove");
82 tokio::fs::remove_file(path)
83 .await
84 .with_context(|| format!("failed removing {:?}", &path))
85 .map_err(|err| Error::new(err, Default::default()))?;
86 if src_metadata.file_type().is_symlink() {
87 prog_track.symlinks_removed.inc();
88 return Ok(Summary {
89 symlinks_removed: 1,
90 ..Default::default()
91 });
92 }
93 prog_track.files_removed.inc();
94 return Ok(Summary {
95 files_removed: 1,
96 ..Default::default()
97 });
98 }
99 tracing::debug!("remove contents of the directory first");
100 if src_metadata.permissions().readonly() {
101 tracing::debug!("directory is read-only - change the permissions");
102 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))
103 .await
104 .with_context(|| {
105 format!(
106 "failed to make '{:?}' directory readable and writeable",
107 &path
108 )
109 })
110 .map_err(|err| Error::new(err, Default::default()))?;
111 }
112 let mut entries = tokio::fs::read_dir(path)
113 .await
114 .with_context(|| format!("failed reading directory {:?}", &path))
115 .map_err(|err| Error::new(err, Default::default()))?;
116 let mut join_set = tokio::task::JoinSet::new();
117 let mut success = true;
118 while let Some(entry) = entries
119 .next_entry()
120 .await
121 .with_context(|| format!("failed traversing directory {:?}", &path))
122 .map_err(|err| Error::new(err, Default::default()))?
123 {
124 throttle::get_ops_token().await;
128 let entry_path = entry.path();
129 let settings = settings.clone();
130 let do_rm = || async move { rm(prog_track, &entry_path, &settings).await };
131 join_set.spawn(do_rm());
132 }
133 drop(entries);
136 let mut rm_summary = Summary {
137 directories_removed: 0,
138 ..Default::default()
139 };
140 while let Some(res) = join_set.join_next().await {
141 match res.map_err(|err| Error::new(err.into(), Default::default()))? {
142 Ok(summary) => rm_summary = rm_summary + summary,
143 Err(error) => {
144 tracing::error!("remove: {:?} failed with: {:#}", path, &error);
145 rm_summary = rm_summary + error.summary;
146 if settings.fail_early {
147 return Err(Error::new(error.source, rm_summary));
148 }
149 success = false;
150 }
151 }
152 }
153 if !success {
154 return Err(Error::new(anyhow!("rm: {:?} failed!", &path), rm_summary));
155 }
156 tracing::debug!("finally remove the empty directory");
157 tokio::fs::remove_dir(path)
158 .await
159 .with_context(|| format!("failed removing directory {:?}", &path))
160 .map_err(|err| Error::new(err, rm_summary))?;
161 prog_track.directories_removed.inc();
162 rm_summary.directories_removed += 1;
163 Ok(rm_summary)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::testutils;
170 use tracing_test::traced_test;
171
172 lazy_static! {
173 static ref PROGRESS: progress::Progress = progress::Progress::new();
174 }
175
176 #[tokio::test]
177 #[traced_test]
178 async fn no_write_permission() -> Result<(), anyhow::Error> {
179 let tmp_dir = testutils::setup_test_dir().await?;
180 let test_path = tmp_dir.as_path();
181 let filepaths = vec![
182 test_path.join("foo").join("0.txt"),
183 test_path.join("foo").join("bar").join("2.txt"),
184 test_path.join("foo").join("baz").join("4.txt"),
185 test_path.join("foo").join("baz"),
186 ];
187 for fpath in &filepaths {
188 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
190 }
191 let summary = rm(
192 &PROGRESS,
193 &test_path.join("foo"),
194 &Settings { fail_early: false },
195 )
196 .await?;
197 assert!(!test_path.join("foo").exists());
198 assert_eq!(summary.files_removed, 5);
199 assert_eq!(summary.symlinks_removed, 2);
200 assert_eq!(summary.directories_removed, 3);
201 Ok(())
202 }
203
204 #[tokio::test]
205 #[traced_test]
206 async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
207 let tmp_dir = testutils::setup_test_dir().await?;
208 let test_path = tmp_dir.as_path();
209 tokio::fs::set_permissions(
211 &test_path.join("foo").join("bar"),
212 std::fs::Permissions::from_mode(0o555),
213 )
214 .await?;
215 let result = rm(
216 &PROGRESS,
217 &test_path.join("foo").join("bar").join("2.txt"),
218 &Settings { fail_early: true },
219 )
220 .await;
221 assert!(result.is_err());
223 let err = result.unwrap_err();
224 let err_string = format!("{:#}", err);
225 assert!(
227 err_string.contains("Permission denied") || err_string.contains("permission denied"),
228 "Error should contain 'Permission denied' but got: {}",
229 err_string
230 );
231 Ok(())
232 }
233}