Skip to main content

slack_rs/auth/
export_import.rs

1//! Export and import commands for profile backup and migration
2
3use 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/// Options for export command
46#[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/// Options for import command
56#[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
64/// Export profiles to encrypted file
65pub fn export_profiles(token_store: &dyn TokenStore, options: &ExportOptions) -> Result<()> {
66    // Require --yes confirmation
67    if !options.yes {
68        return Err(ExportImportError::ConfirmationRequired);
69    }
70
71    // Validate passphrase
72    if options.passphrase.is_empty() {
73        return Err(ExportImportError::EmptyPassphrase);
74    }
75
76    // Load profiles
77    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    // Select profiles to export
83    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        // Default to "default" profile
96        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    // Build export payload
107    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        // Try to get OAuth credentials (non-fatal if not found)
115        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    // Encrypt payload
133    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    // Encode to binary format
144    let encoded = format::encode_export(&payload, &encrypted, &kdf_params)?;
145
146    // Check existing file permissions
147    let output_path = Path::new(&options.output_path);
148    if output_path.exists() {
149        check_file_permissions(output_path)?;
150    }
151
152    // Write to file with 0600 permissions
153    write_secure_file(output_path, &encoded)?;
154
155    Ok(())
156}
157
158/// Import profiles from encrypted file
159pub fn import_profiles(token_store: &dyn TokenStore, options: &ImportOptions) -> Result<()> {
160    // Validate passphrase
161    if options.passphrase.is_empty() {
162        return Err(ExportImportError::EmptyPassphrase);
163    }
164
165    // Read and check file permissions
166    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    // Decode from binary format
172    let decoded = format::decode_export(&encoded_data)?;
173
174    // Decrypt payload
175    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    // Load existing profiles
181    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    // Check for conflicts
187    // Force requires --yes
188    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            // Check if profile name exists
197            if let Some(existing) = config.get(name) {
198                // If same team_id, it's OK (update scenario)
199                if existing.team_id != export_profile.team_id {
200                    return Err(ExportImportError::ProfileExists(name.clone()));
201                }
202            }
203
204            // Check if team_id exists under different name (conflict detection based on team_id only)
205            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    // Import profiles
214    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, // Not exported/imported for security
222            scopes: None,       // Not exported/imported for security
223            bot_scopes: None,   // Not exported/imported for security
224            user_scopes: None,  // Not exported/imported for security
225            default_token_type: None,
226        };
227
228        config.set(name.clone(), profile);
229
230        // Store token
231        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        // Store OAuth client secret if present
235        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
241    save_config(&config_path, &config).map_err(|e| ExportImportError::Storage(e.to_string()))?;
242
243    Ok(())
244}
245
246/// Check file permissions (Unix only)
247#[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    // Check if file is 0600 (owner read/write only)
260    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/// Check file permissions (non-Unix - always succeeds)
271#[cfg(not(unix))]
272fn check_file_permissions(_path: &Path) -> Result<()> {
273    Ok(())
274}
275
276/// Write file with secure permissions (Unix: 0600)
277#[cfg(unix)]
278fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
279    use std::os::unix::fs::PermissionsExt;
280
281    // Write file
282    fs::write(path, data)?;
283
284    // Set permissions to 0600
285    let mut permissions = fs::metadata(path)?.permissions();
286    permissions.set_mode(0o600);
287    fs::set_permissions(path, permissions)?;
288
289    Ok(())
290}
291
292/// Write file with secure permissions (non-Unix - no permission setting)
293#[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        // Set up test profile
368        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        // Set up token store
387        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        // Export (this will use default_config_path, so we need to work around that)
392        // For now, skip the full integration test as it requires mocking config path
393        // This is covered by crypto and format tests
394    }
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}