Skip to main content

upstream_rs/services/integration/
symlink_manager.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::Path;
4
5#[cfg(windows)]
6use std::ffi::OsStr;
7
8pub struct SymlinkManager<'a> {
9    symlinks_dir: &'a Path,
10}
11
12impl<'a> SymlinkManager<'a> {
13    fn remove_link_path(path: &Path, context_message: &'static str) -> Result<()> {
14        match fs::symlink_metadata(path) {
15            Ok(metadata) => {
16                if metadata.is_dir() && !metadata.file_type().is_symlink() {
17                    anyhow::bail!(
18                        "Refusing to remove directory at '{}' while managing symlink",
19                        path.display()
20                    );
21                }
22                fs::remove_file(path).context(context_message)?;
23            }
24            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
25            Err(err) => return Err(err).context(context_message),
26        }
27
28        Ok(())
29    }
30
31    fn platform_link_path(link: &Path) -> std::path::PathBuf {
32        #[cfg(windows)]
33        {
34            if link.extension() != Some(OsStr::new("exe")) {
35                return link.with_extension("exe");
36            }
37        }
38
39        link.to_path_buf()
40    }
41
42    pub fn new(symlinks_dir: &'a Path) -> Self {
43        Self { symlinks_dir }
44    }
45
46    /// Creates a symbolic link in the symlinks directory pointing to the target file
47    pub fn add_link(&self, exec_path: &Path, name: &str) -> Result<()> {
48        if !exec_path.exists() {
49            anyhow::bail!("Target file not found: {}", exec_path.display());
50        }
51
52        let base_link = self.symlinks_dir.join(name);
53        let symlink = Self::platform_link_path(&base_link);
54
55        // Remove existing link if present.
56        Self::remove_link_path(&symlink, "Failed to remove existing symlink")?;
57        // Cleanup stale pre-fix path variant on Windows.
58        if base_link != symlink {
59            Self::remove_link_path(&base_link, "Failed to remove stale symlink")?;
60        }
61
62        Self::create_symlink(exec_path, &symlink)?;
63        Ok(())
64    }
65
66    /// Removes a symbolic link by its package name
67    pub fn remove_link(&self, name: &str) -> Result<()> {
68        let base_link = self.symlinks_dir.join(name);
69        let symlink = Self::platform_link_path(&base_link);
70
71        Self::remove_link_path(&symlink, "Failed to remove symlink")?;
72        if base_link != symlink {
73            Self::remove_link_path(&base_link, "Failed to remove stale symlink")?;
74        }
75
76        Ok(())
77    }
78
79    #[cfg(unix)]
80    fn create_symlink(target_path: &Path, symlink: &Path) -> Result<()> {
81        std::os::unix::fs::symlink(target_path, symlink).context("Failed to create symlink")
82    }
83
84    #[cfg(windows)]
85    fn create_symlink(target_path: &Path, link: &Path) -> Result<()> {
86        fs::hard_link(target_path, link).context("Failed to create hardlink")
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    #[cfg(unix)]
93    use super::SymlinkManager;
94    #[cfg(unix)]
95    use std::path::{Path, PathBuf};
96    #[cfg(unix)]
97    use std::time::{SystemTime, UNIX_EPOCH};
98    #[cfg(unix)]
99    use std::{fs, io};
100
101    #[cfg(unix)]
102    fn temp_root(name: &str) -> PathBuf {
103        let nanos = SystemTime::now()
104            .duration_since(UNIX_EPOCH)
105            .map(|d| d.as_nanos())
106            .unwrap_or(0);
107        std::env::temp_dir().join(format!("upstream-symlink-test-{name}-{nanos}"))
108    }
109
110    #[cfg(unix)]
111    fn cleanup(path: &Path) -> io::Result<()> {
112        fs::remove_dir_all(path)
113    }
114
115    #[cfg(unix)]
116    #[test]
117    fn add_link_replaces_dangling_symlink() {
118        let root = temp_root("replace-dangling");
119        let symlinks_dir = root.join("symlinks");
120        let missing_target = root.join("missing-target");
121        let new_target = root.join("new-target");
122        let link_name = "arduino";
123        let link_path = symlinks_dir.join(link_name);
124
125        fs::create_dir_all(&symlinks_dir).expect("create symlink dir");
126        fs::write(&new_target, b"new-target").expect("write new target");
127        std::os::unix::fs::symlink(&missing_target, &link_path).expect("create dangling symlink");
128        assert!(
129            !link_path.exists(),
130            "dangling symlink should not exist via exists()"
131        );
132        assert!(
133            fs::symlink_metadata(&link_path).is_ok(),
134            "dangling symlink should still be present on disk"
135        );
136
137        let manager = SymlinkManager::new(&symlinks_dir);
138        manager
139            .add_link(&new_target, link_name)
140            .expect("replace dangling symlink");
141
142        let target = fs::read_link(&link_path).expect("read link target");
143        assert_eq!(target, new_target);
144
145        cleanup(&root).expect("cleanup");
146    }
147
148    #[cfg(unix)]
149    #[test]
150    fn remove_link_removes_dangling_symlink() {
151        let root = temp_root("remove-dangling");
152        let symlinks_dir = root.join("symlinks");
153        let missing_target = root.join("missing-target");
154        let link_name = "arduino";
155        let link_path = symlinks_dir.join(link_name);
156
157        fs::create_dir_all(&symlinks_dir).expect("create symlink dir");
158        std::os::unix::fs::symlink(&missing_target, &link_path).expect("create dangling symlink");
159        assert!(
160            fs::symlink_metadata(&link_path).is_ok(),
161            "dangling symlink should be present before removal"
162        );
163
164        let manager = SymlinkManager::new(&symlinks_dir);
165        manager
166            .remove_link(link_name)
167            .expect("remove dangling symlink");
168
169        assert!(
170            fs::symlink_metadata(&link_path).is_err(),
171            "dangling symlink should be removed"
172        );
173
174        cleanup(&root).expect("cleanup");
175    }
176}