1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SftpCredentials {
28 pub host: String,
30
31 #[serde(default = "default_sftp_port", deserialize_with = "deserialize_port")]
33 pub port: u16,
34
35 pub username: String,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub password: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub private_key: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub passphrase: Option<String>,
49}
50
51fn default_sftp_port() -> u16 {
52 22
53}
54
55fn 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
97fn create_sftp_session(credentials: &SftpCredentials) -> Result<ssh2::Sftp, String> {
103 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 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 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 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 session
158 .sftp()
159 .map_err(|e| format!("Failed to create SFTP session: {}", e))
160}
161
162fn 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#[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#[derive(Debug, Deserialize, CapabilityInput)]
218#[capability_input(display_name = "SFTP List Files Input")]
219pub struct SftpListFilesInput {
220 #[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 #[serde(skip_serializing_if = "Option::is_none")]
230 #[field(skip)]
231 pub _connection: Option<crate::connections::RawConnection>,
232}
233
234#[derive(Debug, Deserialize, CapabilityInput)]
236#[capability_input(display_name = "SFTP Download File Input")]
237pub struct SftpDownloadFileInput {
238 #[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 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
268#[capability_input(display_name = "SFTP Upload File Input")]
269pub struct SftpUploadFileInput {
270 #[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 #[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 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
308#[capability_input(display_name = "SFTP Delete File Input")]
309pub struct SftpDeleteFileInput {
310 #[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 #[serde(skip_serializing_if = "Option::is_none")]
320 #[field(skip)]
321 pub _connection: Option<crate::connections::RawConnection>,
322}
323
324#[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#[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 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 let sftp = create_sftp_session(&credentials)?;
366
367 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#[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 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 let sftp = create_sftp_session(&credentials)?;
409
410 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 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#[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 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 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 let sftp = create_sftp_session(&credentials)?;
458
459 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#[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 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 let sftp = create_sftp_session(&credentials)?;
489
490 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#[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}