runtara_agents/agents/
sftp.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! SFTP agent for file operations over SSH
4//!
5//! This module provides SFTP operations with support for:
6//! - Listing files in a directory
7//! - Downloading files
8//! - Uploading files
9//! - Deleting files
10//!
11//! Uses native ssh2 library for SFTP operations.
12//! Connection credentials should be passed as part of the input.
13
14use runtara_agent_macro::{CapabilityInput, CapabilityOutput, capability};
15use serde::{Deserialize, Deserializer, Serialize};
16use ssh2::Session;
17use std::io::{Read, Write};
18use std::net::TcpStream;
19use std::path::Path;
20
21// ============================================================================
22// SFTP Credentials
23// ============================================================================
24
25/// SFTP connection credentials
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SftpCredentials {
28    /// SFTP server hostname or IP
29    pub host: String,
30
31    /// SFTP server port (default: 22)
32    #[serde(default = "default_sftp_port", deserialize_with = "deserialize_port")]
33    pub port: u16,
34
35    /// Username for authentication
36    pub username: String,
37
38    /// Password for authentication (optional, use if no private key)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub password: Option<String>,
41
42    /// Private key for authentication (PEM format, optional)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub private_key: Option<String>,
45
46    /// Passphrase for private key (optional)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub passphrase: Option<String>,
49}
50
51fn default_sftp_port() -> u16 {
52    22
53}
54
55/// Deserialize port from either string or integer
56fn deserialize_port<'de, D>(deserializer: D) -> Result<u16, D::Error>
57where
58    D: Deserializer<'de>,
59{
60    use serde::de::{self, Visitor};
61
62    struct PortVisitor;
63
64    impl<'de> Visitor<'de> for PortVisitor {
65        type Value = u16;
66
67        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
68            formatter.write_str("a port number as integer or string")
69        }
70
71        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
72        where
73            E: de::Error,
74        {
75            u16::try_from(v).map_err(|_| E::custom(format!("port {} out of range", v)))
76        }
77
78        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
79        where
80            E: de::Error,
81        {
82            u16::try_from(v).map_err(|_| E::custom(format!("port {} out of range", v)))
83        }
84
85        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
86        where
87            E: de::Error,
88        {
89            v.parse::<u16>()
90                .map_err(|_| E::custom(format!("invalid port string: {}", v)))
91        }
92    }
93
94    deserializer.deserialize_any(PortVisitor)
95}
96
97// ============================================================================
98// Helper Functions
99// ============================================================================
100
101/// Create an SFTP session from credentials
102fn create_sftp_session(credentials: &SftpCredentials) -> Result<ssh2::Sftp, String> {
103    // Connect to SSH server
104    let tcp =
105        TcpStream::connect(format!("{}:{}", credentials.host, credentials.port)).map_err(|e| {
106            format!(
107                "Failed to connect to {}:{}: {}",
108                credentials.host, credentials.port, e
109            )
110        })?;
111
112    let mut session = Session::new().map_err(|e| format!("Failed to create SSH session: {}", e))?;
113
114    session.set_tcp_stream(tcp);
115    session
116        .handshake()
117        .map_err(|e| format!("SSH handshake failed: {}", e))?;
118
119    // Authenticate - prefer private key if provided and non-empty, otherwise use password
120    let has_private_key = credentials
121        .private_key
122        .as_ref()
123        .map(|k| !k.trim().is_empty())
124        .unwrap_or(false);
125    let has_password = credentials
126        .password
127        .as_ref()
128        .map(|p| !p.is_empty())
129        .unwrap_or(false);
130
131    if has_private_key {
132        // Authenticate with private key
133        let private_key = credentials.private_key.as_ref().unwrap();
134        session
135            .userauth_pubkey_memory(
136                &credentials.username,
137                None,
138                private_key,
139                credentials.passphrase.as_deref(),
140            )
141            .map_err(|e| format!("Private key authentication failed: {}", e))?;
142    } else if has_password {
143        // Authenticate with password
144        let password = credentials.password.as_ref().unwrap();
145        session
146            .userauth_password(&credentials.username, password)
147            .map_err(|e| format!("Password authentication failed: {}", e))?;
148    } else {
149        return Err("No authentication method provided (need password or private_key)".to_string());
150    }
151
152    if !session.authenticated() {
153        return Err("SSH authentication failed".to_string());
154    }
155
156    // Create SFTP session
157    session
158        .sftp()
159        .map_err(|e| format!("Failed to create SFTP session: {}", e))
160}
161
162/// Parse credentials from connection resolved via resolve_connection()
163fn get_credentials_from_input(
164    connection_id: &str,
165    _input_json: Option<&serde_json::Value>,
166) -> Result<SftpCredentials, String> {
167    // Use resolve_connection which checks:
168    // 1. Current capability input (has _connection from connection service)
169    // 2. Thread-local storage (legacy - testing service)
170    let raw_conn = crate::connections::resolve_connection(connection_id)?;
171
172    serde_json::from_value(raw_conn.parameters.clone())
173        .map_err(|e| format!("Failed to parse SFTP credentials: {}", e))
174}
175
176// ============================================================================
177// Input/Output Types
178// ============================================================================
179
180/// File information returned by list operations
181#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
182#[capability_output(
183    display_name = "File Info",
184    description = "Information about a file or directory from SFTP listing"
185)]
186pub struct FileInfo {
187    #[field(
188        display_name = "Name",
189        description = "The name of the file or directory",
190        example = "document.txt"
191    )]
192    pub name: String,
193
194    #[field(
195        display_name = "Path",
196        description = "The full path to the file or directory",
197        example = "/home/user/documents/document.txt"
198    )]
199    pub path: String,
200
201    #[field(
202        display_name = "Size",
203        description = "The size of the file in bytes",
204        example = "1024"
205    )]
206    pub size: u64,
207
208    #[field(
209        display_name = "Is Directory",
210        description = "Whether this entry is a directory",
211        example = "false"
212    )]
213    pub is_directory: bool,
214
215    #[field(
216        display_name = "Modified Time",
217        description = "The last modified timestamp (Unix epoch seconds)"
218    )]
219    pub modified_time: Option<i64>,
220}
221
222/// Input for SFTP list files operation
223#[derive(Debug, Deserialize, CapabilityInput)]
224#[capability_input(display_name = "SFTP List Files Input")]
225pub struct SftpListFilesInput {
226    /// Connection ID for SFTP credentials (auto-injected)
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub connection_id: String,
229
230    /// The directory path to list
231    #[field(
232        display_name = "Directory Path",
233        description = "Path to the directory to list (use \"/\" for root)",
234        example = "/data/uploads"
235    )]
236    pub path: String,
237}
238
239/// Input for SFTP download file operation
240#[derive(Debug, Deserialize, CapabilityInput)]
241#[capability_input(display_name = "SFTP Download File Input")]
242pub struct SftpDownloadFileInput {
243    /// Connection ID for SFTP credentials (auto-injected)
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub connection_id: String,
246
247    /// The file path to download
248    #[field(
249        display_name = "File Path",
250        description = "Full path to the file to download",
251        example = "/data/uploads/document.pdf"
252    )]
253    pub path: String,
254
255    /// Response format (text or base64)
256    #[field(
257        display_name = "Response Format",
258        description = "Format for the downloaded content: \"text\" for text files, \"base64\" for binary files",
259        example = "text",
260        default = "text"
261    )]
262    #[serde(default = "default_response_format")]
263    pub response_format: String,
264}
265
266fn default_response_format() -> String {
267    "text".to_string()
268}
269
270/// Input for SFTP upload file operation
271#[derive(Debug, Deserialize, CapabilityInput)]
272#[capability_input(display_name = "SFTP Upload File Input")]
273pub struct SftpUploadFileInput {
274    /// Connection ID for SFTP credentials (auto-injected)
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub connection_id: String,
277
278    /// The destination file path
279    #[field(
280        display_name = "Destination Path",
281        description = "Full path where the file should be uploaded",
282        example = "/data/uploads/new-file.txt"
283    )]
284    pub path: String,
285
286    /// The file content to upload
287    #[field(
288        display_name = "File Content",
289        description = "Content to upload (plain text or base64-encoded binary)",
290        example = "Hello, World!"
291    )]
292    pub content: String,
293
294    /// Content format (text or base64)
295    #[field(
296        display_name = "Content Format",
297        description = "Format of the content: \"text\" for plain text, \"base64\" for binary data",
298        example = "text",
299        default = "text"
300    )]
301    #[serde(default = "default_content_format")]
302    pub content_format: String,
303}
304
305fn default_content_format() -> String {
306    "text".to_string()
307}
308
309/// Input for SFTP delete file operation
310#[derive(Debug, Deserialize, CapabilityInput)]
311#[capability_input(display_name = "SFTP Delete File Input")]
312pub struct SftpDeleteFileInput {
313    /// Connection ID for SFTP credentials (auto-injected)
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub connection_id: String,
316
317    /// The file path to delete
318    #[field(
319        display_name = "File Path",
320        description = "Full path to the file to delete",
321        example = "/data/uploads/old-file.txt"
322    )]
323    pub path: String,
324}
325
326/// Response for successful delete operation
327#[derive(Debug, Serialize, CapabilityOutput)]
328#[capability_output(
329    display_name = "Delete File Response",
330    description = "Response from deleting a file via SFTP"
331)]
332pub struct DeleteFileResponse {
333    #[field(
334        display_name = "Success",
335        description = "Whether the deletion was successful",
336        example = "true"
337    )]
338    pub success: bool,
339
340    #[field(
341        display_name = "Path",
342        description = "The path of the deleted file",
343        example = "/home/user/documents/old-file.txt"
344    )]
345    pub path: String,
346}
347
348// ============================================================================
349// Operations
350// ============================================================================
351
352/// List files in an SFTP directory
353#[capability(
354    module = "sftp",
355    display_name = "List Files",
356    description = "List files and directories in an SFTP directory"
357)]
358pub fn sftp_list_files(input: SftpListFilesInput) -> Result<Vec<FileInfo>, String> {
359    // Get credentials from input (provided by caller via _connection)
360    let credentials = get_credentials_from_input(&input.connection_id, None)?;
361
362    // Create SFTP session
363    let sftp = create_sftp_session(&credentials)?;
364
365    // List directory
366    let path = Path::new(&input.path);
367    let entries = sftp
368        .readdir(path)
369        .map_err(|e| format!("Failed to list files in path '{}': {}", input.path, e))?;
370
371    let files: Vec<FileInfo> = entries
372        .into_iter()
373        .map(|(path, stat)| {
374            let name = path
375                .file_name()
376                .map(|s| s.to_string_lossy().to_string())
377                .unwrap_or_default();
378            FileInfo {
379                name,
380                path: path.to_string_lossy().to_string(),
381                size: stat.size.unwrap_or(0),
382                is_directory: stat.is_dir(),
383                modified_time: stat.mtime.map(|t| t as i64),
384            }
385        })
386        .collect();
387
388    Ok(files)
389}
390
391/// Download a file from SFTP
392#[capability(
393    module = "sftp",
394    display_name = "Download File",
395    description = "Download a file from SFTP and return its content"
396)]
397pub fn sftp_download_file(input: SftpDownloadFileInput) -> Result<String, String> {
398    // Get credentials from input (provided by caller via _connection)
399    let credentials = get_credentials_from_input(&input.connection_id, None)?;
400
401    // Create SFTP session
402    let sftp = create_sftp_session(&credentials)?;
403
404    // Open and read file
405    let path = Path::new(&input.path);
406    let mut file = sftp
407        .open(path)
408        .map_err(|e| format!("Failed to open file '{}': {}", input.path, e))?;
409
410    let mut file_bytes = Vec::new();
411    file.read_to_end(&mut file_bytes)
412        .map_err(|e| format!("Failed to read file '{}': {}", input.path, e))?;
413
414    // Return based on format
415    match input.response_format.as_str() {
416        "base64" => {
417            use base64::{Engine as _, engine::general_purpose};
418            Ok(general_purpose::STANDARD.encode(&file_bytes))
419        }
420        _ => Ok(String::from_utf8_lossy(&file_bytes).to_string()),
421    }
422}
423
424/// Upload a file to SFTP
425#[capability(
426    module = "sftp",
427    display_name = "Upload File",
428    description = "Upload a file to SFTP",
429    side_effects = true
430)]
431pub fn sftp_upload_file(input: SftpUploadFileInput) -> Result<usize, String> {
432    // Get credentials from input (provided by caller via _connection)
433    let credentials = get_credentials_from_input(&input.connection_id, None)?;
434
435    // Decode content based on format
436    let content_bytes = match input.content_format.as_str() {
437        "base64" => {
438            use base64::{Engine as _, engine::general_purpose};
439            general_purpose::STANDARD
440                .decode(&input.content)
441                .map_err(|e| format!("Failed to decode base64 content: {}", e))?
442        }
443        _ => input.content.into_bytes(),
444    };
445
446    // Create SFTP session
447    let sftp = create_sftp_session(&credentials)?;
448
449    // Create and write file
450    let path = Path::new(&input.path);
451    let mut file = sftp
452        .create(path)
453        .map_err(|e| format!("Failed to create file '{}': {}", input.path, e))?;
454
455    let bytes_written = file
456        .write(&content_bytes)
457        .map_err(|e| format!("Failed to write to file '{}': {}", input.path, e))?;
458
459    Ok(bytes_written)
460}
461
462/// Delete a file from SFTP
463#[capability(
464    module = "sftp",
465    display_name = "Delete File",
466    description = "Delete a file from SFTP",
467    side_effects = true
468)]
469pub fn sftp_delete_file(input: SftpDeleteFileInput) -> Result<DeleteFileResponse, String> {
470    // Get credentials from input (provided by caller via _connection)
471    let credentials = get_credentials_from_input(&input.connection_id, None)?;
472
473    // Create SFTP session
474    let sftp = create_sftp_session(&credentials)?;
475
476    // Delete file
477    let path = Path::new(&input.path);
478    sftp.unlink(path)
479        .map_err(|e| format!("Failed to delete file '{}': {}", input.path, e))?;
480
481    Ok(DeleteFileResponse {
482        success: true,
483        path: input.path,
484    })
485}
486
487// ============================================================================
488// Tests
489// ============================================================================
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_file_info_serialization() {
497        let file = FileInfo {
498            name: "test.txt".to_string(),
499            path: "/data/test.txt".to_string(),
500            size: 1024,
501            is_directory: false,
502            modified_time: Some(1609459200),
503        };
504
505        let json = serde_json::to_string(&file).unwrap();
506        assert!(json.contains("test.txt"));
507        assert!(json.contains("1024"));
508    }
509
510    #[test]
511    fn test_default_formats() {
512        assert_eq!(default_response_format(), "text");
513        assert_eq!(default_content_format(), "text");
514    }
515}