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_input(
164 connection_id: &str,
165 _input_json: Option<&serde_json::Value>,
166) -> Result<SftpCredentials, String> {
167 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#[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#[derive(Debug, Deserialize, CapabilityInput)]
224#[capability_input(display_name = "SFTP List Files Input")]
225pub struct SftpListFilesInput {
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub connection_id: String,
229
230 #[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#[derive(Debug, Deserialize, CapabilityInput)]
241#[capability_input(display_name = "SFTP Download File Input")]
242pub struct SftpDownloadFileInput {
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub connection_id: String,
246
247 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
272#[capability_input(display_name = "SFTP Upload File Input")]
273pub struct SftpUploadFileInput {
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub connection_id: String,
277
278 #[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 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
311#[capability_input(display_name = "SFTP Delete File Input")]
312pub struct SftpDeleteFileInput {
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub connection_id: String,
316
317 #[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#[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#[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 let credentials = get_credentials_from_input(&input.connection_id, None)?;
361
362 let sftp = create_sftp_session(&credentials)?;
364
365 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#[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 let credentials = get_credentials_from_input(&input.connection_id, None)?;
400
401 let sftp = create_sftp_session(&credentials)?;
403
404 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 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#[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 let credentials = get_credentials_from_input(&input.connection_id, None)?;
434
435 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 let sftp = create_sftp_session(&credentials)?;
448
449 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#[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 let credentials = get_credentials_from_input(&input.connection_id, None)?;
472
473 let sftp = create_sftp_session(&credentials)?;
475
476 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#[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}