chiral_client/
file.rs

1use ftp::FtpStream;
2use std::io::Write;
3use std::fs::File;
4
5pub struct FtpClient {
6    ftp_addr: String,
7    ftp_port: u16,
8    user_email: String,
9    token_api: String,
10    user_id: String,
11    ftp: Option<FtpStream>,
12    root_dir: Option<String>,
13}
14
15impl FtpClient {
16    pub fn new(addr: &str, port: u16, email: &str, token: &str, user_id: &str) -> Self {
17        FtpClient {
18            ftp_addr: addr.to_string(),
19            ftp_port: port,
20            user_email: email.to_string(),
21            token_api: token.to_string(),
22            user_id: user_id.to_string(),
23            ftp: None,
24            root_dir: None,
25        }
26    }
27
28    pub fn connect(&mut self) -> Result<(), ftp::FtpError> {
29        let address = format!("{}:{}", self.ftp_addr, self.ftp_port);
30        let mut ftp_stream = FtpStream::connect(address)?;
31        ftp_stream.login(&self.user_email, &self.token_api)?;
32
33        // Try to change into the user's subdirectory
34        match ftp_stream.cwd(&self.user_id) {
35            Ok(_) => {
36                println!("Directory '{}' exists. Switched into it.", self.user_id);
37            }
38            Err(cwd_error) => {
39                println!("Directory '{}' does not exist. Attempting to create...", self.user_id);
40                
41                // Try to create the directory
42                match ftp_stream.mkdir(&self.user_id) {
43                    Ok(_) => {
44                        println!("Directory '{}' created successfully.", self.user_id);
45                        // Now try to change into it
46                        match ftp_stream.cwd(&self.user_id) {
47                            Ok(_) => {
48                                println!("Successfully switched into directory '{}'.", self.user_id);
49                            }
50                            Err(cwd_error2) => {
51                                println!("Warning: Created directory '{}' but couldn't switch into it: {:?}", self.user_id, cwd_error2);
52                                println!("Continuing without switching to user directory.");
53                            }
54                        }
55                    }
56                    Err(mkdir_error) => {
57                        println!("Warning: Could not create directory '{}': {:?}", self.user_id, mkdir_error);
58                        println!("Original cwd error: {cwd_error:?}");
59                        println!("Continuing without user directory - working from root.");
60                    }
61                }
62            }
63        }
64
65        let current_dir = ftp_stream.pwd()?;
66        println!("Connected and current directory: {current_dir}");
67
68        self.root_dir = Some(current_dir);
69        self.ftp = Some(ftp_stream);
70        Ok(())
71    }
72
73
74    pub fn disconnect(&mut self) {
75        if let Some(mut ftp) = self.ftp.take() {
76            let _ = ftp.quit(); // ignore error
77        }
78        self.root_dir = None;
79    }
80
81    pub fn is_connected(&self) -> bool {
82    self.ftp.is_some()
83    }
84
85    pub fn download_file(&mut self, remote_path: &str, local_path: &str) -> Result<(), ftp::FtpError> {
86        let ftp_stream = match &mut self.ftp {
87            Some(ftp) => ftp,
88            None => {
89                return Err(ftp::FtpError::ConnectionError(
90                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
91                ))
92            }
93        };
94
95        let data = ftp_stream.simple_retr(remote_path)?;
96
97        let mut file = File::create(local_path).map_err(|e| {
98            ftp::FtpError::ConnectionError(e)
99        })?;
100
101        file.write_all(&data.into_inner()).map_err(|e| {
102            ftp::FtpError::ConnectionError(e)
103        })?;
104
105        Ok(())
106    }
107
108    pub fn upload_file(&mut self, local_path: &str, remote_path: &str) -> Result<(), ftp::FtpError> {
109        let ftp_stream = match &mut self.ftp {
110            Some(ftp) => ftp,
111            None => {
112                return Err(ftp::FtpError::ConnectionError(
113                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
114                ))
115            }
116        };
117
118        let mut file = File::open(local_path).map_err(ftp::FtpError::ConnectionError)?;
119        ftp_stream.put(remote_path, &mut file)?;
120
121        Ok(())
122    }
123    pub fn current_directory(&mut self) -> Result<String, ftp::FtpError> {
124        let ftp_stream = match &mut self.ftp {
125            Some(ftp) => ftp,
126            None => {
127                return Err(ftp::FtpError::ConnectionError(
128                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
129                ))
130            }
131        };
132
133        ftp_stream.pwd()
134    }
135    pub fn check_if_directory_exists(&mut self, dir: &str) -> Result<bool, ftp::FtpError> {
136        let ftp_stream = match &mut self.ftp {
137            Some(ftp) => ftp,
138            None => {
139                return Err(ftp::FtpError::ConnectionError(
140                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
141                ))
142            }
143        };
144
145        // Save current working directory
146        let original_dir = ftp_stream.pwd()?;
147
148        // Try changing to the target directory
149        let exists = ftp_stream.cwd(dir).is_ok();
150
151        // Change back to the original directory (ignore failure here)
152        let _ = ftp_stream.cwd(&original_dir);
153
154        Ok(exists)
155    }
156
157
158    pub fn make_directory(&mut self, dir_name: &str) -> Result<(), ftp::FtpError> {
159        let ftp_stream = match &mut self.ftp {
160            Some(ftp) => ftp,
161            None => {
162                return Err(ftp::FtpError::ConnectionError(
163                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
164                ))
165            }
166        };
167
168        ftp_stream.mkdir(dir_name)?;
169        println!("Created directory: {dir_name}");
170
171        Ok(())
172    }
173
174
175    pub fn change_directory(&mut self, dir: &str) -> Result<(), ftp::FtpError> {
176        let ftp_stream = match &mut self.ftp {
177            Some(ftp) => ftp,
178            None => {
179                return Err(ftp::FtpError::ConnectionError(
180                    std::io::Error::new(std::io::ErrorKind::NotConnected, "Not connected to FTP server"),
181                ))
182            }
183        };  
184        ftp_stream.cwd(dir)?;
185        let current_dir = ftp_stream.pwd()?;
186        self.root_dir = Some(current_dir.clone());
187
188        println!("Changed directory to: {current_dir}");
189
190        Ok(())
191    }
192
193    pub fn remove_directory_recursive(&mut self, dir_path: &str) -> Result<(), ftp::FtpError> {
194        let ftp_stream = self.ftp.as_mut().ok_or_else(|| {
195            ftp::FtpError::ConnectionError(std::io::Error::new(
196                std::io::ErrorKind::NotConnected,
197                "Not connected to FTP server",
198            ))
199        })?;
200
201        Self::delete_recursive(ftp_stream, dir_path)
202    }
203
204    fn delete_recursive(ftp: &mut FtpStream, dir_path: &str) -> Result<(), ftp::FtpError> {
205        println!("Processing directory: {dir_path}");
206
207        // Store current working directory to restore later
208        let current_dir = ftp.pwd()?;
209        
210        let entries = match ftp.nlst(Some(dir_path)) {
211            Ok(entries) => entries,
212            Err(e) => {
213                println!("Could not list directory {dir_path}: {e:?}");
214                return Err(e);
215            }
216        };
217
218        for entry in entries {
219            if entry.ends_with("/.") || entry.ends_with("/..") {
220                continue;
221            }
222
223            let entry_name = if entry.starts_with(dir_path) {
224                entry.strip_prefix(dir_path)
225                    .unwrap_or(&entry)
226                    .trim_start_matches('/')
227            } else if entry.contains('/') {
228                entry.split('/').next_back().unwrap_or(&entry)
229            } else {
230                &entry
231            };
232
233            if entry_name.is_empty() || entry_name == "." || entry_name == ".." {
234                continue;
235            }
236
237            let full_path = format!("{}/{}", dir_path.trim_end_matches('/'), entry_name);
238
239            // Distinguish directory vs file by trying cwd
240            match ftp.cwd(&full_path) {
241                Ok(_) => {
242                    // Restore working directory before recursive call
243                    ftp.cwd(&current_dir)?;
244                    println!("Found subdirectory: {full_path}");
245                    Self::delete_recursive(ftp, &full_path)?;
246                }
247                Err(_) => {
248                    println!("Attempting to delete file: {full_path}");
249                    ftp.rm(&full_path).map(|_| {
250                        println!("Deleted file: {full_path}");
251                    }).map_err(|e| {
252                        println!("Could not delete file {full_path}: {e:?}");
253                        e
254                    })?;
255                }
256            }
257        }
258
259        println!("Removing directory: {dir_path}");
260        ftp.rmdir(dir_path).map(|_| {
261            println!("Successfully deleted directory: {dir_path}");
262        }).map_err(|e| {
263            println!("Failed to delete directory {dir_path}: {e:?}");
264            e
265        })
266    }
267
268
269    pub fn remove_file(&mut self, path: &str) -> Result<(), ftp::FtpError> {
270        let ftp_stream = self.ftp.as_mut().ok_or_else(|| {
271            ftp::FtpError::ConnectionError(std::io::Error::new(
272                std::io::ErrorKind::NotConnected,
273                "Not connected to FTP server",
274            ))
275        })?;
276
277        ftp_stream.rm(path)
278    }
279
280
281}
282
283impl Drop for FtpClient {
284    fn drop(&mut self) {
285        self.disconnect();
286    }
287}
288
289
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use std::fs::{self, File};
295    use std::io::Write;
296    use std::net::TcpListener;
297    use std::sync::mpsc;
298    use std::thread::{self, JoinHandle};
299    use std::time::{Duration, Instant};
300    use tokio::select;
301    use uuid::Uuid;
302    use unftp_sbe_fs::Filesystem;
303    use libunftp::{auth::AnonymousAuthenticator, ServerBuilder};
304
305    fn spawn_test_ftp_server_with_shutdown_ready() -> (JoinHandle<()>, String, std::sync::mpsc::Sender<()>) {
306
307        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
308        let addr = listener.local_addr().unwrap().to_string();
309        drop(listener);
310
311        let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
312        let (ready_tx, ready_rx) = mpsc::channel::<()>();
313
314        let handle = thread::spawn({
315            let addr_clone = addr.clone();
316            move || {
317                let runtime = tokio::runtime::Runtime::new().unwrap();
318                runtime.block_on(async {
319                    let backend_factory = || Filesystem::new(std::env::temp_dir());
320
321                    let server = ServerBuilder::new(Box::new(backend_factory))
322                        .authenticator(std::sync::Arc::new(AnonymousAuthenticator {}))
323                        .build()
324                        .unwrap();
325
326                    let (async_shutdown_tx, mut async_shutdown_rx) = tokio::sync::mpsc::unbounded_channel();
327                    let sync_rx = shutdown_rx;
328
329                    tokio::spawn(async move {
330                        let _ = tokio::task::spawn_blocking(move || sync_rx.recv()).await;
331                        let _ = async_shutdown_tx.send(());
332                    });
333
334                    let server_task = tokio::spawn({
335                        let addr_clone = addr_clone.clone();
336                        async move {
337                            server.listen(addr_clone).await
338                        }
339                    });
340
341                    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
342                    let _ = ready_tx.send(());
343
344                    select! {
345                        result = server_task => {
346                            if let Err(e) = result.unwrap() {
347                                eprintln!("FTP Server error: {e}");
348                            }
349                        }
350                        _ = async_shutdown_rx.recv() => {
351                            println!("Shutdown signal received, stopping server");
352                        }
353                    }
354                });
355            }
356        });
357
358        ready_rx.recv().expect("Server failed to start");
359
360        (handle, addr, shutdown_tx)
361    }
362
363    fn wait_for_server_ready(addr: &str) {
364        let addr_parts: Vec<&str> = addr.split(':').collect();
365        let host = addr_parts[0];
366        let port: u16 = addr_parts[1].parse().expect("Invalid port");
367
368        let start_time = Instant::now();
369        let timeout = Duration::from_secs(10);
370
371        while start_time.elapsed() < timeout {
372            let mut test_client = FtpClient::new(host, port, "anonymous", "", "test_user");
373            if test_client.connect().is_ok() {
374                test_client.disconnect();
375                return;
376            }
377            thread::sleep(Duration::from_millis(50));
378        }
379
380        panic!("Server did not become ready within timeout period");
381    }
382
383    #[test]
384    fn test_connection() {
385        let (handle, addr, shutdown_tx) = spawn_test_ftp_server_with_shutdown_ready();
386        wait_for_server_ready(&addr);
387
388        let addr_parts: Vec<&str> = addr.split(':').collect();
389        let host = addr_parts[0];
390        let port: u16 = addr_parts[1].parse().expect("Invalid port");
391
392        let test_credentials = vec![
393            ("anonymous", "", "test_user"),
394            ("test", "test", "test_user"),
395            ("ftp", "", "test_user"),
396        ];  
397
398        let mut connection_successful = false;
399        for (user, pass, initial_dir) in test_credentials {
400            let mut client = FtpClient::new(host, port, user, pass, initial_dir);
401
402            match client.connect() {
403                Ok(_) => {
404                    assert!(client.is_connected());
405                    client.disconnect();
406                    assert!(!client.is_connected());
407
408                    connection_successful = true;
409                    break;
410                }
411                Err(e) => {
412                    println!("Failed to connect with {user}/{pass}: {e}");
413                }
414            }
415        }
416
417        shutdown_tx.send(()).expect("Failed to send shutdown");
418        handle.join().expect("Server thread panicked");
419
420        assert!(connection_successful, "Failed to connect with any credentials");
421    }
422
423    #[test]
424    fn test_file_upload_and_download() {
425        let (handle, addr, shutdown_tx) = spawn_test_ftp_server_with_shutdown_ready();
426        wait_for_server_ready(&addr);
427
428        let addr_parts: Vec<&str> = addr.split(':').collect();
429        let host = addr_parts[0];
430        let port: u16 = addr_parts[1].parse().expect("Invalid port");
431
432        let mut client = FtpClient::new(host, port, "anonymous", "", "test_user");
433        client.connect().expect("Failed to connect");
434
435        client.make_directory("upload").ok();
436
437        let local_path = "test_upload.txt";
438        let mut file = File::create(local_path).unwrap();
439        writeln!(file, "Hello FTP test").unwrap();
440
441        let remote_path = "upload/test_upload.txt";
442        client.upload_file(local_path, remote_path).expect("Upload failed");
443
444        let download_path = "downloaded_test_upload.txt";
445        client.download_file(remote_path, download_path).expect("Download failed");
446
447        let content = fs::read_to_string(download_path).unwrap();
448        assert!(content.contains("Hello FTP test"));
449
450        fs::remove_file(local_path).unwrap();
451        fs::remove_file(download_path).unwrap();
452        client.remove_directory_recursive("upload").ok();
453        client.disconnect();
454
455        shutdown_tx.send(()).expect("Failed to send shutdown");
456        handle.join().expect("Server thread panicked");
457    }
458
459    #[test]
460    fn test_make_and_change_directory() {
461        let (handle, addr, shutdown_tx) = spawn_test_ftp_server_with_shutdown_ready();
462        wait_for_server_ready(&addr);
463
464        let addr_parts: Vec<&str> = addr.split(':').collect();
465        let host = addr_parts[0];
466        let port: u16 = addr_parts[1].parse().expect("Invalid port");
467
468        let mut client = FtpClient::new(host, port, "anonymous", "", "test_user");
469        client.connect().expect("Failed to connect");
470        let _ = client.current_directory();
471        // Ensure user root is correct
472        let user_root = "upload";
473        client.make_directory(user_root).ok();
474        let _ = client.current_directory();
475        
476        let uuid = Uuid::new_v4();
477        let dir = format!("{user_root}/test_dir_{uuid}");
478        client.make_directory(&dir).expect("Failed to create dir");
479
480        println!("Directory Made: {dir}");
481        client.change_directory(&dir).expect("Failed to change dir");
482        let _ = client.current_directory();
483        assert!(client.is_connected());
484        client.disconnect();
485
486        shutdown_tx.send(()).expect("Failed to send shutdown");
487        handle.join().expect("Server thread panicked");
488    }
489
490    #[test]
491    fn test_check_if_directory_exists() {
492        let (handle, addr, shutdown_tx) = spawn_test_ftp_server_with_shutdown_ready();
493        wait_for_server_ready(&addr);
494
495        let addr_parts: Vec<&str> = addr.split(':').collect();
496        let host = addr_parts[0];
497        let port: u16 = addr_parts[1].parse().expect("Invalid port");
498
499        let mut client = FtpClient::new(host, port, "anonymous", "", "test_user");
500        client.connect().expect("Failed to connect");
501
502        let uuid1 = Uuid::new_v4();
503        let uuid2 = Uuid::new_v4();
504
505        let dir_name_1 = format!("test_del_{uuid1}");
506        let dir_name_2 = format!("test_del_{uuid2}");
507        let full_path_1 = format!("upload1/{dir_name_1}");
508
509        client.make_directory("upload1").ok();
510        client.make_directory(&full_path_1).expect("Could not create root dir");
511
512        client.change_directory("upload1");
513
514        // ✅ should exist
515        assert_eq!(
516            client.check_if_directory_exists(&dir_name_1).expect("Failed to check"),
517            true
518        );
519
520        // ❌ should not exist
521        assert_eq!(
522            client.check_if_directory_exists(&dir_name_2).expect("Failed to check"),
523            false
524        );
525    }
526
527
528
529    #[test]
530    fn test_recursive_delete_directory() {
531        let (handle, addr, shutdown_tx) = spawn_test_ftp_server_with_shutdown_ready();
532        wait_for_server_ready(&addr);
533
534        let addr_parts: Vec<&str> = addr.split(':').collect();
535        let host = addr_parts[0];
536        let port: u16 = addr_parts[1].parse().expect("Invalid port");
537
538        let mut client = FtpClient::new(host, port, "anonymous", "", "test_user");
539        client.connect().expect("Failed to connect to FTP server");
540
541        // Generate unique root and nested directories
542        let uuid = Uuid::new_v4();
543        let root_dir = format!("upload1/test_del_{uuid}");
544        let sub_dir = format!("{root_dir}/nested");
545
546        // Create directories
547        client.make_directory("upload1").ok();
548        client.make_directory(&root_dir).expect("Could not create root dir");
549        client.make_directory(&sub_dir).expect("Could not create nested dir");
550
551        // Create local temp files
552        let temp_dir = std::env::temp_dir();
553        let file1_path = temp_dir.join(format!("ftp_temp_root_{uuid}.txt"));
554        let file2_path = temp_dir.join(format!("ftp_temp_nested_{uuid}.txt"));
555
556        std::fs::write(&file1_path, "Root level file\n").expect("Failed to write temp file1");
557        std::fs::write(&file2_path, "Nested file\n").expect("Failed to write temp file2");
558
559        // Upload files to FTP server
560        let remote1 = format!("{root_dir}/file1.txt");
561        let remote2 = format!("{sub_dir}/file2.txt");
562        client.upload_file(file1_path.to_str().unwrap(), &remote1).expect("Upload file1 failed");
563        client.upload_file(file2_path.to_str().unwrap(), &remote2).expect("Upload file2 failed");
564
565        println!("Files uploaded to test directories");
566
567        // ✅ Recursive deletion from inside /test_user
568        client.remove_directory_recursive(&root_dir).expect("Recursive deletion failed");
569
570        // Clean up local temp files
571        let _ = std::fs::remove_file(&file1_path);
572        let _ = std::fs::remove_file(&file2_path);
573
574        client.disconnect();
575        println!("Disconnected");
576
577        shutdown_tx.send(()).expect("Failed to send shutdown");
578        handle.join().expect("Server thread panicked");
579    }
580
581}