yolk/
deploy.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Stdio,
4};
5
6use fs_err::PathExt as _;
7use miette::{Context as _, IntoDiagnostic};
8use normalize_path::NormalizePath as _;
9use which::which_global;
10
11use crate::util::PathExt as _;
12
13/// Struct that keeps track of the deployment and undeployment process of multiple symlinks.
14///
15/// We keep track of all created symlinks, as well as all symlinks where the creation or deletion failed due to insufficient permissions.
16/// In case of missing permissions, you can then use [`Deployer::try_run_elevated()`] to retry the operation with elevated privileges.
17#[derive(Default, Debug)]
18pub struct Deployer {
19    /// Symlinks that were successfully created
20    created_symlinks: Vec<PathBuf>,
21    /// Symlink creation mappings (actual_path, symlink_path) that failed due to insufficient permissions
22    missing_permissions_create: Vec<(PathBuf, PathBuf)>,
23    /// Symlink deletion paths (symlink_path) that failed due to insufficient permissions
24    missing_permissions_remove: Vec<PathBuf>,
25}
26
27impl Deployer {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn created_symlinks(&self) -> &Vec<PathBuf> {
33        &self.created_symlinks
34    }
35
36    pub fn failed_creations(&self) -> &Vec<(PathBuf, PathBuf)> {
37        &self.missing_permissions_create
38    }
39
40    pub fn failed_deletions(&self) -> &Vec<PathBuf> {
41        &self.missing_permissions_remove
42    }
43
44    pub fn add_created_symlink(&mut self, link_path: PathBuf) {
45        self.created_symlinks.push(link_path);
46    }
47
48    /// Create a symlink from at the path `link` pointing to the `original` file.
49    pub fn create_symlink(
50        &mut self,
51        original: impl AsRef<Path>,
52        link: impl AsRef<Path>,
53    ) -> miette::Result<()> {
54        let link = link.as_ref();
55        let original = original.as_ref();
56        tracing::trace!("Creating symlink at {} -> {}", link.abbr(), original.abbr());
57
58        if let Err(err) = symlink::symlink_auto(original, link) {
59            if err.kind() != std::io::ErrorKind::PermissionDenied {
60                return Err(err).into_diagnostic().wrap_err_with(|| {
61                    format!(
62                        "Failed to create symlink at {}",
63                        format_symlink(link.abbr(), original.abbr())
64                    )
65                })?;
66            }
67            self.missing_permissions_create
68                .push((original.to_path_buf(), link.to_path_buf()));
69        } else {
70            self.created_symlinks.push(link.to_path_buf());
71        }
72        return Ok(());
73    }
74    /// Remove a symlink from at the path `link` pointing to the `original` file.
75    pub fn delete_symlink(&mut self, path: impl AsRef<Path>) -> miette::Result<()> {
76        let path = path.as_ref();
77        tracing::trace!("Deleting symlink at {}", path.abbr());
78        if !path.is_symlink() {
79            miette::bail!("Path is not a symlink: {}", path.abbr());
80        }
81        let result = if path.symlink_metadata().into_diagnostic()?.is_dir() {
82            symlink::remove_symlink_dir(path)
83        } else {
84            match symlink::remove_symlink_file(path) {
85                Ok(()) => Ok(()),
86                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Err(e),
87                Err(e) => {
88                    tracing::debug!(
89                        "Failed to remove file symlink, trying dir symlink removal as fallback: {:?}", e
90                    );
91                    symlink::remove_symlink_dir(path)
92                }
93            }
94        };
95        match result {
96            Ok(()) => {}
97            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
98                self.missing_permissions_remove.push(path.to_path_buf());
99            }
100            Err(e) => {
101                return Err(e)
102                    .into_diagnostic()
103                    .wrap_err(format!("Failed to remove symlink at {}", path.abbr()))
104            }
105        }
106        Ok(())
107    }
108
109    /// Set up a symlink from the given `link_path` to the given `actual_path`, recursively.
110    /// Also takes the `egg_root` dir, to ensure we can safely delete any stale symlinks on the way there.
111    ///
112    /// Requires all paths to be absolute, will panic otherwise.
113    ///
114    /// This means:
115    /// - If `link_path` exists and is a file, abort
116    /// - If `link_path` exists and is a symlink into the egg dir, remove the symlink and then continue.
117    /// - If `actual_path` is a file, symlink.
118    /// - If `actual_path` is a directory that does not exist in `link_path`, symlink it.
119    /// - If `actual_path` is a directory that already exists in `link_path`, recurse into it and `symlink_recursive` `actual_path`s children.
120    #[tracing::instrument(skip_all, fields(
121        egg_root = egg_root.as_ref().abbr(),
122        actual_path = actual_path.as_ref().abbr(),
123        link_path = link_path.as_ref().abbr()
124    ))]
125    pub fn symlink_recursive(
126        &mut self,
127        egg_root: impl AsRef<Path>,
128        actual_path: impl AsRef<Path>,
129        link_path: &impl AsRef<Path>,
130    ) -> miette::Result<()> {
131        fn inner(
132            deployer: &mut Deployer,
133            egg_root: PathBuf,
134            actual_path: PathBuf,
135            link_path: PathBuf,
136        ) -> miette::Result<()> {
137            let actual_path = actual_path.normalize();
138            let link_path = link_path.normalize();
139            let egg_root = egg_root.normalize();
140            link_path.assert_absolute("link_path");
141            actual_path.assert_absolute("actual_path");
142            actual_path.assert_starts_with(&egg_root, "actual_path");
143            tracing::trace!(
144                "symlink_recursive({}, {})",
145                actual_path.abbr(),
146                link_path.abbr()
147            );
148
149            let actual_path = actual_path.canonical()?;
150
151            if link_path.is_symlink() {
152                let link_target = link_path.fs_err_read_link().into_diagnostic()?;
153                if link_target == actual_path {
154                    deployer.add_created_symlink(link_path);
155                    return Ok(());
156                } else if link_target.exists() {
157                    miette::bail!(
158                        "Failed to create symlink {}, as a file already exists there",
159                        format_symlink(link_path.abbr(), actual_path.abbr())
160                    );
161                } else if link_target.starts_with(&egg_root) {
162                    tracing::info!(
163                        "Removing dead symlink {}",
164                        format_symlink(link_path.abbr(), link_target.abbr())
165                    );
166                    deployer.delete_symlink(&link_path)?;
167                    cov_mark::hit!(remove_dead_symlink);
168                    // After we've removed that file, creating the symlink later will succeed!
169                } else {
170                    miette::bail!(
171                        "Encountered dead symlink, but it doesn't target the egg dir: {}",
172                        link_path.abbr(),
173                    );
174                }
175            } else if link_path.exists() {
176                tracing::trace!("link_path exists as non-symlink {}", link_path.abbr());
177                if link_path.is_dir() && actual_path.is_dir() {
178                    for entry in actual_path.fs_err_read_dir().into_diagnostic()? {
179                        let entry = entry.into_diagnostic()?;
180                        deployer.symlink_recursive(
181                            &egg_root,
182                            entry.path(),
183                            &link_path.join(entry.file_name()),
184                        )?;
185                    }
186                    return Ok(());
187                } else if link_path.is_dir() || actual_path.is_dir() {
188                    miette::bail!(
189                        "Conflicting file or directory {} with {}",
190                        actual_path.abbr(),
191                        link_path.abbr()
192                    );
193                }
194            }
195            deployer.create_symlink(&actual_path, &link_path)?;
196            tracing::info!(
197                "created symlink {}",
198                format_symlink(link_path.abbr(), actual_path.abbr()),
199            );
200            Ok(())
201        }
202        inner(
203            self,
204            egg_root.as_ref().to_path_buf(),
205            actual_path.as_ref().to_path_buf(),
206            link_path.as_ref().to_path_buf(),
207        )
208    }
209
210    #[tracing::instrument(skip(actual_path, link_path), fields(
211        actual_path = actual_path.as_ref().abbr(),
212        link_path = link_path.as_ref().abbr()
213    ))]
214    pub fn remove_symlink_recursive(
215        &mut self,
216        actual_path: impl AsRef<Path>,
217        link_path: &impl AsRef<Path>,
218    ) -> miette::Result<()> {
219        let actual_path = actual_path.as_ref();
220        let link_path = link_path.as_ref();
221        if link_path.is_symlink() && link_path.canonical()? == actual_path {
222            tracing::info!(
223                "Removing symlink {}",
224                format_symlink(link_path.abbr(), actual_path.abbr())
225            );
226            self.delete_symlink(link_path)?;
227        } else if link_path.is_dir() && actual_path.is_dir() {
228            for entry in actual_path.fs_err_read_dir().into_diagnostic()? {
229                let entry = entry.into_diagnostic()?;
230                self.remove_symlink_recursive(entry.path(), &link_path.join(entry.file_name()))?;
231            }
232        } else if link_path.exists() {
233            miette::bail!(
234                help = "Yolk will only try to remove files that are symlinks pointing into the corresponding egg.",
235                "Tried to remove deployment of {}, but {} doesn't link to it",
236                actual_path.abbr(),
237                link_path.abbr()
238            );
239        }
240        Ok(())
241    }
242
243    /// Retry running symlink creation and deletion with root priviledges.
244    pub fn try_run_elevated(self) -> miette::Result<()> {
245        if self.missing_permissions_create.is_empty() && self.missing_permissions_remove.is_empty()
246        {
247            tracing::trace!("No priviledge escalation necessary, all symlink operations succeeded");
248            return Ok(());
249        }
250        let yolk_binary = std::env::args().nth(0).unwrap_or("yolk".to_string());
251        let yolk_binary_path = if yolk_binary.starts_with('/') {
252            yolk_binary
253        } else {
254            which_global(yolk_binary)
255                .map(|x| x.to_string_lossy().to_string())
256                .unwrap_or_else(|_| "yolk".to_string())
257        };
258        let args = [yolk_binary_path, "root-manage-symlinks".to_string()]
259            .into_iter()
260            .chain(
261                self.missing_permissions_create
262                    .iter()
263                    .map(|(original, symlink)| {
264                        [
265                            "--create-symlink".to_string(),
266                            format!("{}::::{}", original.display(), symlink.display()),
267                        ]
268                    })
269                    .flatten(),
270            )
271            .chain(
272                self.missing_permissions_remove
273                    .iter()
274                    .map(|symlink| {
275                        [
276                            "--delete-symlink".to_string(),
277                            symlink.to_string_lossy().to_string(),
278                        ]
279                    })
280                    .flatten(),
281            )
282            .collect::<Vec<_>>();
283        tracing::info!(
284            "Some symlink operations require root permissions: {} {}",
285            if self.missing_permissions_create.is_empty() {
286                "".to_string()
287            } else {
288                format!(
289                    "create {}",
290                    self.missing_permissions_create
291                        .iter()
292                        .map(|x| format!("{}", x.1.display()))
293                        .collect::<Vec<_>>()
294                        .join(", ")
295                )
296            },
297            if self.missing_permissions_remove.is_empty() {
298                "".to_string()
299            } else {
300                format!(
301                    "delete {}",
302                    self.missing_permissions_remove
303                        .iter()
304                        .map(|x| format!("{}", x.display()))
305                        .collect::<Vec<_>>()
306                        .join(", ")
307                )
308            }
309        );
310        try_sudo(&args)?;
311        Ok(())
312    }
313}
314
315/// Create a symlink at `link` pointing to `original`.
316pub fn create_symlink(original: impl AsRef<Path>, link: impl AsRef<Path>) -> miette::Result<()> {
317    let link = link.as_ref();
318    let original = original.as_ref();
319    tracing::trace!("Creating symlink at {} -> {}", link.abbr(), original.abbr());
320    symlink::symlink_auto(original, link)
321        .into_diagnostic()
322        .wrap_err_with(|| {
323            format!(
324                "Failed to create symlink at {}",
325                format_symlink(link.abbr(), original.abbr())
326            )
327        })?;
328    Ok(())
329}
330
331/// Delete a symlink at `path`, but only if it actually is a symlink.
332pub fn remove_symlink(path: impl AsRef<Path>) -> miette::Result<()> {
333    let path = path.as_ref();
334    if !path.is_symlink() {
335        miette::bail!("Path is not a symlink: {}", path.abbr());
336    }
337    if path.symlink_metadata().into_diagnostic()?.is_dir() {
338        symlink::remove_symlink_dir(path)
339            .into_diagnostic()
340            .wrap_err_with(|| format!("Failed to remove symlink dir at {}", path.abbr()))?;
341    } else {
342        let result = symlink::remove_symlink_file(path);
343        if let Err(e) = result {
344            symlink::remove_symlink_dir(path)
345                .into_diagnostic()
346                .wrap_err("Failed to remove symlink dir as fallback from symlink file")
347                .wrap_err_with(|| {
348                    format!("Failed to remove symlink file at {}: {e:?}", path.abbr())
349                })?;
350        }
351    }
352    Ok(())
353}
354
355fn try_sudo(args: &[String]) -> miette::Result<()> {
356    let sudo_command = which_global("sudo")
357        .or_else(|_| which_global("doas"))
358        .or_else(|_| which_global("run0"))
359        .map_err(|_| miette::miette!("No sudo, doas, or run0 command found"))?;
360
361    let mut cmd = std::process::Command::new(sudo_command);
362    cmd.stdin(Stdio::inherit())
363        .stdout(Stdio::inherit())
364        .stderr(Stdio::piped())
365        .args(args);
366    let output = cmd.output().into_diagnostic()?;
367    if !output.status.success() {
368        tracing::error!(
369            "Failed to run command with sudo: {}",
370            String::from_utf8_lossy(&output.stderr)
371        );
372    }
373    Ok(())
374}
375
376fn format_symlink(link_path: impl AsRef<Path>, original_path: impl AsRef<Path>) -> String {
377    format!(
378        "{} -> {}",
379        link_path.as_ref().display(),
380        original_path.as_ref().display()
381    )
382}