Skip to main content

rush_sync_server/sync/
profiles.rs

1use crate::core::helpers::get_base_dir;
2use crate::core::prelude::*;
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const DEFAULT_REMOTE_PORT: u16 = 22;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct RemoteProfile {
10    pub host: String,
11    pub user: String,
12    #[serde(default = "default_remote_port")]
13    pub port: u16,
14    pub remote_path: String,
15    #[serde(default)]
16    pub identity_file: Option<String>,
17}
18
19fn default_remote_port() -> u16 {
20    DEFAULT_REMOTE_PORT
21}
22
23impl RemoteProfile {
24    pub fn new(
25        user: String,
26        host: String,
27        remote_path: String,
28        port: u16,
29        identity_file: Option<String>,
30    ) -> Result<Self> {
31        if user.trim().is_empty() {
32            return Err(AppError::Validation(
33                "Remote user cannot be empty".to_string(),
34            ));
35        }
36        if host.trim().is_empty() {
37            return Err(AppError::Validation(
38                "Remote host cannot be empty".to_string(),
39            ));
40        }
41        if remote_path.trim().is_empty() {
42            return Err(AppError::Validation(
43                "Remote path cannot be empty".to_string(),
44            ));
45        }
46        if remote_path.contains('\n') || remote_path.contains('\r') {
47            return Err(AppError::Validation(
48                "Remote path contains invalid newline characters".to_string(),
49            ));
50        }
51        if !remote_path.starts_with('/') {
52            return Err(AppError::Validation(
53                "Remote path must be absolute (start with '/')".to_string(),
54            ));
55        }
56        if remote_path.contains("..") {
57            return Err(AppError::Validation(
58                "Remote path must not contain '..'".to_string(),
59            ));
60        }
61        if port == 0 {
62            return Err(AppError::Validation("Remote port must be > 0".to_string()));
63        }
64
65        if let Some(ref identity) = identity_file {
66            if identity.contains("..") {
67                return Err(AppError::Validation(
68                    "Identity file path must not contain '..'".to_string(),
69                ));
70            }
71        }
72
73        Ok(Self {
74            host,
75            user,
76            port,
77            remote_path,
78            identity_file,
79        })
80    }
81
82    pub fn ssh_target(&self) -> String {
83        format!("{}@{}", self.user, self.host)
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88struct ProfilesFile {
89    #[serde(default)]
90    profiles: HashMap<String, RemoteProfile>,
91}
92
93#[derive(Debug, Clone)]
94pub struct RemoteProfileStore {
95    path: PathBuf,
96}
97
98impl RemoteProfileStore {
99    pub fn new() -> Result<Self> {
100        let base_dir = get_base_dir()?;
101        Ok(Self {
102            path: base_dir.join(".rss").join("remotes.toml"),
103        })
104    }
105
106    #[cfg(test)]
107    pub fn with_path(path: PathBuf) -> Self {
108        Self { path }
109    }
110
111    pub fn path(&self) -> &Path {
112        &self.path
113    }
114
115    pub fn list(&self) -> Result<Vec<(String, RemoteProfile)>> {
116        let file = self.load_file()?;
117        let mut entries: Vec<_> = file.profiles.into_iter().collect();
118        entries.sort_by(|a, b| a.0.cmp(&b.0));
119        Ok(entries)
120    }
121
122    pub fn get(&self, name: &str) -> Result<RemoteProfile> {
123        let file = self.load_file()?;
124        file.profiles.get(name).cloned().ok_or_else(|| {
125            AppError::Validation(format!(
126                "Remote profile '{}' not found. Use 'remote list' to inspect configured remotes.",
127                name
128            ))
129        })
130    }
131
132    pub fn exists(&self, name: &str) -> Result<bool> {
133        let file = self.load_file()?;
134        Ok(file.profiles.contains_key(name))
135    }
136
137    pub fn upsert(&self, name: &str, profile: RemoteProfile) -> Result<()> {
138        validate_profile_name(name)?;
139
140        let mut file = self.load_file()?;
141        file.profiles.insert(name.to_string(), profile);
142        self.save_file(&file)
143    }
144
145    pub fn remove(&self, name: &str) -> Result<()> {
146        let mut file = self.load_file()?;
147        if file.profiles.remove(name).is_none() {
148            return Err(AppError::Validation(format!(
149                "Remote profile '{}' not found",
150                name
151            )));
152        }
153        self.save_file(&file)
154    }
155
156    fn load_file(&self) -> Result<ProfilesFile> {
157        if !self.path.exists() {
158            return Ok(ProfilesFile::default());
159        }
160
161        let content = std::fs::read_to_string(&self.path).map_err(AppError::Io)?;
162        toml::from_str::<ProfilesFile>(&content)
163            .map_err(|e| AppError::Validation(format!("Failed to parse remotes file: {}", e)))
164    }
165
166    fn save_file(&self, file: &ProfilesFile) -> Result<()> {
167        if let Some(parent) = self.path.parent() {
168            std::fs::create_dir_all(parent).map_err(AppError::Io)?;
169        }
170
171        let serialized = toml::to_string_pretty(file).map_err(|e| {
172            AppError::Validation(format!("Failed to serialize remotes file: {}", e))
173        })?;
174
175        std::fs::write(&self.path, serialized).map_err(AppError::Io)
176    }
177}
178
179pub fn validate_profile_name(name: &str) -> Result<()> {
180    if name.trim().is_empty() {
181        return Err(AppError::Validation(
182            "Remote profile name cannot be empty".to_string(),
183        ));
184    }
185    if name.len() > 64 {
186        return Err(AppError::Validation(
187            "Remote profile name too long (max 64 chars)".to_string(),
188        ));
189    }
190    if !name
191        .chars()
192        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
193    {
194        return Err(AppError::Validation(
195            "Remote profile name may only contain a-z, A-Z, 0-9, '-' and '_'".to_string(),
196        ));
197    }
198    Ok(())
199}
200
201pub fn parse_user_host(input: &str) -> Result<(String, String)> {
202    let (user, host) = input
203        .split_once('@')
204        .ok_or_else(|| AppError::Validation("Expected '<user>@<host>' format".to_string()))?;
205
206    let user = user.trim();
207    let host = host.trim();
208
209    if user.is_empty() || host.is_empty() {
210        return Err(AppError::Validation(
211            "Both user and host are required in '<user>@<host>'".to_string(),
212        ));
213    }
214
215    if user.contains(|c: char| c.is_whitespace() || c == ';' || c == '&' || c == '|' || c == '$')
216    {
217        return Err(AppError::Validation(
218            "User contains invalid characters".to_string(),
219        ));
220    }
221    if host.contains(|c: char| c.is_whitespace() || c == ';' || c == '&' || c == '|' || c == '$')
222    {
223        return Err(AppError::Validation(
224            "Host contains invalid characters".to_string(),
225        ));
226    }
227
228    Ok((user.to_string(), host.to_string()))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn parse_user_host_ok() {
237        let (user, host) = parse_user_host("deploy@example.com").expect("must parse");
238        assert_eq!(user, "deploy");
239        assert_eq!(host, "example.com");
240    }
241
242    #[test]
243    fn validate_profile_name_rejects_space() {
244        let res = validate_profile_name("prod west");
245        assert!(res.is_err());
246    }
247
248    #[test]
249    fn rejects_relative_remote_path() {
250        let res = RemoteProfile::new("u".into(), "h".into(), "relative/path".into(), 22, None);
251        assert!(res.is_err());
252    }
253
254    #[test]
255    fn rejects_path_traversal_remote_path() {
256        let res = RemoteProfile::new("u".into(), "h".into(), "/opt/../etc".into(), 22, None);
257        assert!(res.is_err());
258    }
259
260    #[test]
261    fn rejects_identity_with_traversal() {
262        let res = RemoteProfile::new(
263            "u".into(),
264            "h".into(),
265            "/opt/app".into(),
266            22,
267            Some("../../etc/passwd".into()),
268        );
269        assert!(res.is_err());
270    }
271
272    #[test]
273    fn accepts_valid_profile() {
274        let res = RemoteProfile::new(
275            "deploy".into(),
276            "example.com".into(),
277            "/opt/app".into(),
278            22,
279            Some("~/.ssh/id_ed25519".into()),
280        );
281        assert!(res.is_ok());
282    }
283
284    #[test]
285    fn parse_user_host_rejects_shell_chars() {
286        assert!(parse_user_host("user;cmd@host").is_err());
287        assert!(parse_user_host("user@host$(cmd)").is_err());
288    }
289
290    #[test]
291    fn store_roundtrip() {
292        let temp_dir = std::env::temp_dir().join(format!(
293            "rush-sync-test-{}",
294            std::time::SystemTime::now()
295                .duration_since(std::time::UNIX_EPOCH)
296                .unwrap_or_default()
297                .as_nanos()
298        ));
299        let file_path = temp_dir.join("remotes.toml");
300        let store = RemoteProfileStore::with_path(file_path.clone());
301
302        let profile = RemoteProfile::new(
303            "deploy".to_string(),
304            "example.com".to_string(),
305            "/opt/app".to_string(),
306            22,
307            None,
308        )
309        .expect("profile");
310
311        store.upsert("prod", profile).expect("save");
312        let loaded = store.get("prod").expect("get");
313        assert_eq!(loaded.user, "deploy");
314        assert_eq!(loaded.host, "example.com");
315
316        let _ = std::fs::remove_file(file_path);
317        let _ = std::fs::remove_dir_all(temp_dir);
318    }
319}