1use crate::auth::crypto::{self, KdfParams};
4use crate::auth::format::{self, ExportPayload, ExportProfile};
5use crate::profile::{
6 default_config_path, get_oauth_client_secret, load_config, make_token_key, save_config,
7 store_oauth_client_secret, Profile, TokenStore, TokenStoreError,
8};
9use std::fs;
10use std::io;
11use std::path::Path;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum ExportImportError {
16 #[error("Profile not found: {0}")]
17 ProfileNotFound(String),
18 #[error("Token not found for profile: {0}")]
19 TokenNotFound(String),
20 #[error("No profiles to export")]
21 NoProfiles,
22 #[error("Export requires --yes flag for confirmation")]
23 ConfirmationRequired,
24 #[error("Profile already exists: {0} (use --force to overwrite)")]
25 ProfileExists(String),
26 #[error("Empty passphrase not allowed")]
27 EmptyPassphrase,
28 #[cfg(unix)]
29 #[error("File permission error: {0}")]
30 PermissionError(String),
31 #[error("IO error: {0}")]
32 Io(#[from] io::Error),
33 #[error("Crypto error: {0}")]
34 Crypto(#[from] crypto::CryptoError),
35 #[error("Format error: {0}")]
36 Format(#[from] format::FormatError),
37 #[error("Storage error: {0}")]
38 Storage(String),
39 #[error("Token store error: {0}")]
40 TokenStore(#[from] TokenStoreError),
41}
42
43pub type Result<T> = std::result::Result<T, ExportImportError>;
44
45#[derive(Debug, Clone)]
47pub struct ExportOptions {
48 pub profile_name: Option<String>,
49 pub all: bool,
50 pub output_path: String,
51 pub passphrase: String,
52 pub yes: bool,
53}
54
55#[derive(Debug, Clone)]
57pub struct ImportOptions {
58 pub input_path: String,
59 pub passphrase: String,
60 pub yes: bool,
61 pub force: bool,
62}
63
64pub fn export_profiles(token_store: &dyn TokenStore, options: &ExportOptions) -> Result<()> {
66 if !options.yes {
68 return Err(ExportImportError::ConfirmationRequired);
69 }
70
71 if options.passphrase.is_empty() {
73 return Err(ExportImportError::EmptyPassphrase);
74 }
75
76 let config_path =
78 default_config_path().map_err(|e| ExportImportError::Storage(e.to_string()))?;
79 let config =
80 load_config(&config_path).map_err(|e| ExportImportError::Storage(e.to_string()))?;
81
82 let profiles_to_export: Vec<(String, &Profile)> = if options.all {
84 config
85 .profiles
86 .iter()
87 .map(|(k, v)| (k.clone(), v))
88 .collect()
89 } else if let Some(ref profile_name) = options.profile_name {
90 let profile = config
91 .get(profile_name)
92 .ok_or_else(|| ExportImportError::ProfileNotFound(profile_name.clone()))?;
93 vec![(profile_name.clone(), profile)]
94 } else {
95 let profile = config
97 .get("default")
98 .ok_or_else(|| ExportImportError::ProfileNotFound("default".to_string()))?;
99 vec![("default".to_string(), profile)]
100 };
101
102 if profiles_to_export.is_empty() {
103 return Err(ExportImportError::NoProfiles);
104 }
105
106 let mut payload = ExportPayload::new();
108 for (name, profile) in profiles_to_export {
109 let token_key = make_token_key(&profile.team_id, &profile.user_id);
110 let token = token_store
111 .get(&token_key)
112 .map_err(|_| ExportImportError::TokenNotFound(name.clone()))?;
113
114 let client_id = profile.client_id.clone();
116 let client_secret = get_oauth_client_secret(token_store, &name).ok();
117
118 payload.profiles.insert(
119 name,
120 ExportProfile {
121 team_id: profile.team_id.clone(),
122 user_id: profile.user_id.clone(),
123 team_name: profile.team_name.clone(),
124 user_name: profile.user_name.clone(),
125 token,
126 client_id,
127 client_secret,
128 },
129 );
130 }
131
132 let kdf_params = KdfParams {
134 salt: crypto::generate_salt(),
135 ..Default::default()
136 };
137
138 let key = crypto::derive_key(&options.passphrase, &kdf_params)?;
139 let payload_json = serde_json::to_vec(&payload)
140 .map_err(|e| ExportImportError::Format(format::FormatError::Json(e)))?;
141 let encrypted = crypto::encrypt(&payload_json, &key)?;
142
143 let encoded = format::encode_export(&payload, &encrypted, &kdf_params)?;
145
146 let output_path = Path::new(&options.output_path);
148 if output_path.exists() {
149 check_file_permissions(output_path)?;
150 }
151
152 write_secure_file(output_path, &encoded)?;
154
155 Ok(())
156}
157
158pub fn import_profiles(token_store: &dyn TokenStore, options: &ImportOptions) -> Result<()> {
160 if options.passphrase.is_empty() {
162 return Err(ExportImportError::EmptyPassphrase);
163 }
164
165 let input_path = Path::new(&options.input_path);
167 check_file_permissions(input_path)?;
168
169 let encoded_data = fs::read(input_path)?;
170
171 let decoded = format::decode_export(&encoded_data)?;
173
174 let key = crypto::derive_key(&options.passphrase, &decoded.kdf_params)?;
176 let payload_json = crypto::decrypt(&decoded.encrypted_data, &key)?;
177 let payload: ExportPayload = serde_json::from_slice(&payload_json)
178 .map_err(|e| ExportImportError::Format(format::FormatError::Json(e)))?;
179
180 let config_path =
182 default_config_path().map_err(|e| ExportImportError::Storage(e.to_string()))?;
183 let mut config =
184 load_config(&config_path).map_err(|e| ExportImportError::Storage(e.to_string()))?;
185
186 if options.force && !options.yes {
189 return Err(ExportImportError::Storage(
190 "--force requires --yes to confirm overwrite".to_string(),
191 ));
192 }
193
194 if !options.force {
195 for (name, export_profile) in &payload.profiles {
196 if let Some(existing) = config.get(name) {
198 if existing.team_id != export_profile.team_id {
200 return Err(ExportImportError::ProfileExists(name.clone()));
201 }
202 }
203
204 for (existing_name, existing_profile) in &config.profiles {
206 if existing_name != name && existing_profile.team_id == export_profile.team_id {
207 return Err(ExportImportError::ProfileExists(existing_name.clone()));
208 }
209 }
210 }
211 }
212
213 for (name, export_profile) in payload.profiles {
215 let profile = Profile {
216 team_id: export_profile.team_id.clone(),
217 user_id: export_profile.user_id.clone(),
218 team_name: export_profile.team_name,
219 user_name: export_profile.user_name,
220 client_id: export_profile.client_id.clone(),
221 redirect_uri: None, scopes: None, bot_scopes: None, user_scopes: None, default_token_type: None,
226 };
227
228 config.set(name.clone(), profile);
229
230 let token_key = make_token_key(&export_profile.team_id, &export_profile.user_id);
232 token_store.set(&token_key, &export_profile.token)?;
233
234 if let Some(client_secret) = export_profile.client_secret {
236 store_oauth_client_secret(token_store, &name, &client_secret)?;
237 }
238 }
239
240 save_config(&config_path, &config).map_err(|e| ExportImportError::Storage(e.to_string()))?;
242
243 Ok(())
244}
245
246#[cfg(unix)]
248fn check_file_permissions(path: &Path) -> Result<()> {
249 use std::os::unix::fs::PermissionsExt;
250
251 if !path.exists() {
252 return Ok(());
253 }
254
255 let metadata = fs::metadata(path)?;
256 let permissions = metadata.permissions();
257 let mode = permissions.mode();
258
259 if mode & 0o777 != 0o600 {
261 return Err(ExportImportError::PermissionError(format!(
262 "File must have 0600 permissions, found: {:o}",
263 mode & 0o777
264 )));
265 }
266
267 Ok(())
268}
269
270#[cfg(not(unix))]
272fn check_file_permissions(_path: &Path) -> Result<()> {
273 Ok(())
274}
275
276#[cfg(unix)]
278fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
279 use std::os::unix::fs::PermissionsExt;
280
281 fs::write(path, data)?;
283
284 let mut permissions = fs::metadata(path)?.permissions();
286 permissions.set_mode(0o600);
287 fs::set_permissions(path, permissions)?;
288
289 Ok(())
290}
291
292#[cfg(not(unix))]
294fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
295 fs::write(path, data)?;
296 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::profile::{InMemoryTokenStore, ProfilesConfig};
303 use tempfile::TempDir;
304
305 #[test]
306 fn test_export_requires_yes_flag() {
307 let token_store = InMemoryTokenStore::new();
308 let options = ExportOptions {
309 profile_name: None,
310 all: false,
311 output_path: "/tmp/test.export".to_string(),
312 passphrase: "password".to_string(),
313 yes: false,
314 };
315
316 let result = export_profiles(&token_store, &options);
317 assert!(result.is_err());
318 assert!(matches!(
319 result.unwrap_err(),
320 ExportImportError::ConfirmationRequired
321 ));
322 }
323
324 #[test]
325 fn test_export_empty_passphrase() {
326 let token_store = InMemoryTokenStore::new();
327 let options = ExportOptions {
328 profile_name: None,
329 all: false,
330 output_path: "/tmp/test.export".to_string(),
331 passphrase: "".to_string(),
332 yes: true,
333 };
334
335 let result = export_profiles(&token_store, &options);
336 assert!(result.is_err());
337 assert!(matches!(
338 result.unwrap_err(),
339 ExportImportError::EmptyPassphrase
340 ));
341 }
342
343 #[test]
344 fn test_import_empty_passphrase() {
345 let token_store = InMemoryTokenStore::new();
346 let options = ImportOptions {
347 input_path: "/tmp/test.export".to_string(),
348 passphrase: "".to_string(),
349 yes: false,
350 force: false,
351 };
352
353 let result = import_profiles(&token_store, &options);
354 assert!(result.is_err());
355 assert!(matches!(
356 result.unwrap_err(),
357 ExportImportError::EmptyPassphrase
358 ));
359 }
360
361 #[test]
362 fn test_export_import_round_trip() {
363 let temp_dir = TempDir::new().unwrap();
364 let config_path = temp_dir.path().join("profiles.json");
365 let _export_path = temp_dir.path().join("export.dat");
366
367 let mut config = ProfilesConfig::new();
369 config.set(
370 "test".to_string(),
371 Profile {
372 team_id: "T123".to_string(),
373 user_id: "U456".to_string(),
374 team_name: Some("Test Team".to_string()),
375 user_name: Some("Test User".to_string()),
376 client_id: None,
377 redirect_uri: None,
378 scopes: None,
379 bot_scopes: None,
380 user_scopes: None,
381 default_token_type: None,
382 },
383 );
384 save_config(&config_path, &config).unwrap();
385
386 let token_store = InMemoryTokenStore::new();
388 let token_key = make_token_key("T123", "U456");
389 token_store.set(&token_key, "xoxb-test-token").unwrap();
390
391 }
395
396 #[cfg(unix)]
397 #[test]
398 fn test_write_secure_file_permissions() {
399 use std::os::unix::fs::PermissionsExt;
400
401 let temp_dir = TempDir::new().unwrap();
402 let file_path = temp_dir.path().join("secure.dat");
403
404 write_secure_file(&file_path, b"test data").unwrap();
405
406 let metadata = fs::metadata(&file_path).unwrap();
407 let mode = metadata.permissions().mode();
408
409 assert_eq!(mode & 0o777, 0o600, "File should have 0600 permissions");
410 }
411}