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 data
163fn get_credentials_from_connection(
164    connection: &crate::connections::RawConnection,
165) -> Result<SftpCredentials, String> {
166    serde_json::from_value(connection.parameters.clone())
167        .map_err(|e| format!("Failed to parse SFTP credentials: {}", e))
168}
169
170// ============================================================================
171// Input/Output Types
172// ============================================================================
173
174/// File information returned by list operations
175#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
176#[capability_output(
177    display_name = "File Info",
178    description = "Information about a file or directory from SFTP listing"
179)]
180pub struct FileInfo {
181    #[field(
182        display_name = "Name",
183        description = "The name of the file or directory",
184        example = "document.txt"
185    )]
186    pub name: String,
187
188    #[field(
189        display_name = "Path",
190        description = "The full path to the file or directory",
191        example = "/home/user/documents/document.txt"
192    )]
193    pub path: String,
194
195    #[field(
196        display_name = "Size",
197        description = "The size of the file in bytes",
198        example = "1024"
199    )]
200    pub size: u64,
201
202    #[field(
203        display_name = "Is Directory",
204        description = "Whether this entry is a directory",
205        example = "false"
206    )]
207    pub is_directory: bool,
208
209    #[field(
210        display_name = "Modified Time",
211        description = "The last modified timestamp (Unix epoch seconds)"
212    )]
213    pub modified_time: Option<i64>,
214}
215
216/// Input for SFTP list files operation
217#[derive(Debug, Deserialize, CapabilityInput)]
218#[capability_input(display_name = "SFTP List Files Input")]
219pub struct SftpListFilesInput {
220    /// The directory path to list
221    #[field(
222        display_name = "Directory Path",
223        description = "Path to the directory to list (use \"/\" for root)",
224        example = "/data/uploads"
225    )]
226    pub path: String,
227
228    /// Connection data injected by workflow runtime (internal use)
229    #[serde(skip_serializing_if = "Option::is_none")]
230    #[field(skip)]
231    pub _connection: Option<crate::connections::RawConnection>,
232}
233
234/// Input for SFTP download file operation
235#[derive(Debug, Deserialize, CapabilityInput)]
236#[capability_input(display_name = "SFTP Download File Input")]
237pub struct SftpDownloadFileInput {
238    /// The file path to download
239    #[field(
240        display_name = "File Path",
241        description = "Full path to the file to download",
242        example = "/data/uploads/document.pdf"
243    )]
244    pub path: String,
245
246    /// Response format (text or base64)
247    #[field(
248        display_name = "Response Format",
249        description = "Format for the downloaded content: \"text\" for text files, \"base64\" for binary files",
250        example = "text",
251        default = "text"
252    )]
253    #[serde(default = "default_response_format")]
254    pub response_format: String,
255
256    /// Connection data injected by workflow runtime (internal use)
257    #[serde(skip_serializing_if = "Option::is_none")]
258    #[field(skip)]
259    pub _connection: Option<crate::connections::RawConnection>,
260}
261
262fn default_response_format() -> String {
263    "text".to_string()
264}
265
266/// Input for SFTP upload file operation
267#[derive(Debug, Deserialize, CapabilityInput)]
268#[capability_input(display_name = "SFTP Upload File Input")]
269pub struct SftpUploadFileInput {
270    /// The destination file path
271    #[field(
272        display_name = "Destination Path",
273        description = "Full path where the file should be uploaded",
274        example = "/data/uploads/new-file.txt"
275    )]
276    pub path: String,
277
278    /// The file content to upload
279    #[field(
280        display_name = "File Content",
281        description = "Content to upload (plain text or base64-encoded binary)",
282        example = "Hello, World!"
283    )]
284    pub content: String,
285
286    /// Content format (text or base64)
287    #[field(
288        display_name = "Content Format",
289        description = "Format of the content: \"text\" for plain text, \"base64\" for binary data",
290        example = "text",
291        default = "text"
292    )]
293    #[serde(default = "default_content_format")]
294    pub content_format: String,
295
296    /// Connection data injected by workflow runtime (internal use)
297    #[serde(skip_serializing_if = "Option::is_none")]
298    #[field(skip)]
299    pub _connection: Option<crate::connections::RawConnection>,
300}
301
302fn default_content_format() -> String {
303    "text".to_string()
304}
305
306/// Input for SFTP delete file operation
307#[derive(Debug, Deserialize, CapabilityInput)]
308#[capability_input(display_name = "SFTP Delete File Input")]
309pub struct SftpDeleteFileInput {
310    /// The file path to delete
311    #[field(
312        display_name = "File Path",
313        description = "Full path to the file to delete",
314        example = "/data/uploads/old-file.txt"
315    )]
316    pub path: String,
317
318    /// Connection data injected by workflow runtime (internal use)
319    #[serde(skip_serializing_if = "Option::is_none")]
320    #[field(skip)]
321    pub _connection: Option<crate::connections::RawConnection>,
322}
323
324/// Response for successful delete operation
325#[derive(Debug, Serialize, CapabilityOutput)]
326#[capability_output(
327    display_name = "Delete File Response",
328    description = "Response from deleting a file via SFTP"
329)]
330pub struct DeleteFileResponse {
331    #[field(
332        display_name = "Success",
333        description = "Whether the deletion was successful",
334        example = "true"
335    )]
336    pub success: bool,
337
338    #[field(
339        display_name = "Path",
340        description = "The path of the deleted file",
341        example = "/home/user/documents/old-file.txt"
342    )]
343    pub path: String,
344}
345
346// ============================================================================
347// Operations
348// ============================================================================
349
350/// List files in an SFTP directory
351#[capability(
352    module = "sftp",
353    display_name = "List Files",
354    description = "List files and directories in an SFTP directory"
355)]
356pub fn sftp_list_files(input: SftpListFilesInput) -> Result<Vec<FileInfo>, String> {
357    // Get credentials from connection data
358    let connection = input
359        ._connection
360        .as_ref()
361        .ok_or("No connection data provided. SFTP requires a connection.")?;
362    let credentials = get_credentials_from_connection(connection)?;
363
364    // Create SFTP session
365    let sftp = create_sftp_session(&credentials)?;
366
367    // List directory
368    let path = Path::new(&input.path);
369    let entries = sftp
370        .readdir(path)
371        .map_err(|e| format!("Failed to list files in path '{}': {}", input.path, e))?;
372
373    let files: Vec<FileInfo> = entries
374        .into_iter()
375        .map(|(path, stat)| {
376            let name = path
377                .file_name()
378                .map(|s| s.to_string_lossy().to_string())
379                .unwrap_or_default();
380            FileInfo {
381                name,
382                path: path.to_string_lossy().to_string(),
383                size: stat.size.unwrap_or(0),
384                is_directory: stat.is_dir(),
385                modified_time: stat.mtime.map(|t| t as i64),
386            }
387        })
388        .collect();
389
390    Ok(files)
391}
392
393/// Download a file from SFTP
394#[capability(
395    module = "sftp",
396    display_name = "Download File",
397    description = "Download a file from SFTP and return its content"
398)]
399pub fn sftp_download_file(input: SftpDownloadFileInput) -> Result<String, String> {
400    // Get credentials from connection data
401    let connection = input
402        ._connection
403        .as_ref()
404        .ok_or("No connection data provided. SFTP requires a connection.")?;
405    let credentials = get_credentials_from_connection(connection)?;
406
407    // Create SFTP session
408    let sftp = create_sftp_session(&credentials)?;
409
410    // Open and read file
411    let path = Path::new(&input.path);
412    let mut file = sftp
413        .open(path)
414        .map_err(|e| format!("Failed to open file '{}': {}", input.path, e))?;
415
416    let mut file_bytes = Vec::new();
417    file.read_to_end(&mut file_bytes)
418        .map_err(|e| format!("Failed to read file '{}': {}", input.path, e))?;
419
420    // Return based on format
421    match input.response_format.as_str() {
422        "base64" => {
423            use base64::{Engine as _, engine::general_purpose};
424            Ok(general_purpose::STANDARD.encode(&file_bytes))
425        }
426        _ => Ok(String::from_utf8_lossy(&file_bytes).to_string()),
427    }
428}
429
430/// Upload a file to SFTP
431#[capability(
432    module = "sftp",
433    display_name = "Upload File",
434    description = "Upload a file to SFTP",
435    side_effects = true
436)]
437pub fn sftp_upload_file(input: SftpUploadFileInput) -> Result<usize, String> {
438    // Get credentials from connection data
439    let connection = input
440        ._connection
441        .as_ref()
442        .ok_or("No connection data provided. SFTP requires a connection.")?;
443    let credentials = get_credentials_from_connection(connection)?;
444
445    // Decode content based on format
446    let content_bytes = match input.content_format.as_str() {
447        "base64" => {
448            use base64::{Engine as _, engine::general_purpose};
449            general_purpose::STANDARD
450                .decode(&input.content)
451                .map_err(|e| format!("Failed to decode base64 content: {}", e))?
452        }
453        _ => input.content.into_bytes(),
454    };
455
456    // Create SFTP session
457    let sftp = create_sftp_session(&credentials)?;
458
459    // Create and write file
460    let path = Path::new(&input.path);
461    let mut file = sftp
462        .create(path)
463        .map_err(|e| format!("Failed to create file '{}': {}", input.path, e))?;
464
465    let bytes_written = file
466        .write(&content_bytes)
467        .map_err(|e| format!("Failed to write to file '{}': {}", input.path, e))?;
468
469    Ok(bytes_written)
470}
471
472/// Delete a file from SFTP
473#[capability(
474    module = "sftp",
475    display_name = "Delete File",
476    description = "Delete a file from SFTP",
477    side_effects = true
478)]
479pub fn sftp_delete_file(input: SftpDeleteFileInput) -> Result<DeleteFileResponse, String> {
480    // Get credentials from connection data
481    let connection = input
482        ._connection
483        .as_ref()
484        .ok_or("No connection data provided. SFTP requires a connection.")?;
485    let credentials = get_credentials_from_connection(connection)?;
486
487    // Create SFTP session
488    let sftp = create_sftp_session(&credentials)?;
489
490    // Delete file
491    let path = Path::new(&input.path);
492    sftp.unlink(path)
493        .map_err(|e| format!("Failed to delete file '{}': {}", input.path, e))?;
494
495    Ok(DeleteFileResponse {
496        success: true,
497        path: input.path,
498    })
499}
500
501// ============================================================================
502// Tests
503// ============================================================================
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_file_info_serialization() {
511        let file = FileInfo {
512            name: "test.txt".to_string(),
513            path: "/data/test.txt".to_string(),
514            size: 1024,
515            is_directory: false,
516            modified_time: Some(1609459200),
517        };
518
519        let json = serde_json::to_string(&file).unwrap();
520        assert!(json.contains("test.txt"));
521        assert!(json.contains("1024"));
522    }
523
524    #[test]
525    fn test_default_formats() {
526        assert_eq!(default_response_format(), "text");
527        assert_eq!(default_content_format(), "text");
528    }
529}