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}