wraith/src-tauri/src/sftp/mod.rs
Vantz Stockwell bc608b0683
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
feat: copilot QoL — resizable panel, SFTP tools, context, error watcher
Resizable panel:
- Drag handle on left border of copilot panel
- Pointer events for smooth resize (320px–1200px range)

SFTP MCP tools:
- sftp_list: list remote directories
- sftp_read: read remote files
- sftp_write: write remote files
- Full HTTP endpoints + bridge tool definitions

Active session context:
- mcp_get_session_context command returns last 20 lines of scrollback
- Frontend can call on tab switch to keep AI informed

Error watcher:
- Background scanner runs every 2 seconds across all sessions
- 20+ error patterns (permission denied, OOM, segfault, disk full, etc.)
- Emits mcp:error events to frontend with session ID and matched line
- Sessions auto-registered with watcher on connect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:30:12 -04:00

326 lines
11 KiB
Rust

//! SFTP service — high-level file operations over an existing SSH connection.
//!
//! SFTP sessions are created from SSH handles via a separate subsystem channel
//! (not the PTY channel). One `SftpSession` is stored per SSH session ID and
//! provides all file operations needed by the frontend.
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use dashmap::DashMap;
use log::{debug, info};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::OpenFlags;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex as TokioMutex;
// ── file entry ───────────────────────────────────────────────────────────────
/// Metadata about a single remote file or directory, returned to the frontend.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
pub name: String,
pub path: String,
pub size: u64,
pub is_dir: bool,
/// Unix permission string, e.g. "0755"
pub permissions: String,
/// Human-readable modification time, e.g. "Mar 17 14:30"
pub mod_time: String,
}
// ── helpers ──────────────────────────────────────────────────────────────────
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
fn format_mtime(unix_secs: u32) -> String {
// Build a SystemTime from the raw epoch value.
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
// in chrono just for this; a simple manual decomposition is sufficient for
// the "Mar 17 14:30" display format expected by the frontend.
let secs = unix_secs as i64;
// Days since epoch → calendar date (Gregorian, ignoring leap seconds).
let days = secs / 86_400;
let time_of_day = secs % 86_400;
let hours = time_of_day / 3_600;
let minutes = (time_of_day % 3_600) / 60;
// Gregorian calendar calculation (Zeller / Julian Day approach).
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let _y = if m <= 2 { y + 1 } else { y };
let month = match m {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "???",
};
// Suppress unused variable warning — st is only used as a sanity anchor.
let _ = st;
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
}
/// Convert raw permission bits to a "0755"-style octal string.
fn format_permissions(raw: Option<u32>) -> String {
match raw {
Some(p) => format!("{:04o}", p & 0o7777),
None => "----".to_string(),
}
}
// ── SFTP service ─────────────────────────────────────────────────────────────
/// Manages SFTP sessions keyed by SSH session ID.
#[derive(Clone)]
pub struct SftpService {
/// One `SftpSession` per SSH session, behind a mutex so async commands can
/// take a shared reference to the `SftpService` and still mutably borrow
/// individual sessions.
clients: DashMap<String, Arc<TokioMutex<SftpSession>>>,
}
impl SftpService {
pub fn new() -> Self {
Self {
clients: DashMap::new(),
}
}
/// Store an SFTP client for the given session.
pub fn register_client(&self, session_id: &str, client: SftpSession) {
info!("SFTP client registered for session {}", session_id);
self.clients
.insert(session_id.to_string(), Arc::new(TokioMutex::new(client)));
}
/// Remove and drop the SFTP client for a session (called on SSH disconnect).
pub fn remove_client(&self, session_id: &str) {
if self.clients.remove(session_id).is_some() {
info!("SFTP client removed for session {}", session_id);
}
}
/// List the contents of a remote directory.
///
/// Returns entries sorted: directories first (alphabetically), then files
/// (alphabetically).
pub async fn list(&self, session_id: &str, path: &str) -> Result<Vec<FileEntry>, String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
let read_dir = client
.read_dir(path)
.await
.map_err(|e| format!("SFTP list '{}' failed: {}", path, e))?;
let mut entries: Vec<FileEntry> = read_dir
.map(|entry| {
let meta = entry.metadata();
let name = entry.file_name();
let full_path = if path.ends_with('/') {
format!("{}{}", path, name)
} else {
format!("{}/{}", path, name)
};
let is_dir = meta.is_dir();
FileEntry {
name,
path: full_path,
size: meta.size.unwrap_or(0),
is_dir,
permissions: format_permissions(meta.permissions),
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
}
})
.collect();
// Sort: directories first, then files; each group alphabetical.
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
debug!(
"SFTP list '{}' for session {}: {} entries",
path,
session_id,
entries.len()
);
Ok(entries)
}
/// Read a remote file as a UTF-8 string.
///
/// Enforces a 5 MB guard — returns an error for larger files.
pub async fn read_file(&self, session_id: &str, path: &str) -> Result<String, String> {
const MAX_BYTES: u64 = 5 * 1024 * 1024;
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
// Stat first to check size before pulling bytes.
let meta = client
.metadata(path)
.await
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
if let Some(size) = meta.size {
if size > MAX_BYTES {
return Err(format!(
"File '{}' is {} bytes — exceeds 5 MB read limit",
path, size
));
}
}
let mut file = client
.open(path)
.await
.map_err(|e| format!("SFTP open '{}' failed: {}", path, e))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.await
.map_err(|e| format!("SFTP read '{}' failed: {}", path, e))?;
String::from_utf8(buf)
.map_err(|_| format!("File '{}' is not valid UTF-8", path))
}
/// Write a UTF-8 string to a remote file (create or truncate).
pub async fn write_file(
&self,
session_id: &str,
path: &str,
content: &str,
) -> Result<(), String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
let mut file = client
.open_with_flags(
path,
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
)
.await
.map_err(|e| format!("SFTP open '{}' for write failed: {}", path, e))?;
file.write_all(content.as_bytes())
.await
.map_err(|e| format!("SFTP write '{}' failed: {}", path, e))?;
file.flush()
.await
.map_err(|e| format!("SFTP flush '{}' failed: {}", path, e))?;
debug!("SFTP wrote {} bytes to '{}'", content.len(), path);
Ok(())
}
/// Create a remote directory.
pub async fn mkdir(&self, session_id: &str, path: &str) -> Result<(), String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
client
.create_dir(path)
.await
.map_err(|e| format!("SFTP mkdir '{}' failed: {}", path, e))
}
/// Delete a remote file or directory.
///
/// Tries `remove_file` first; falls back to `remove_dir` if that fails.
pub async fn delete(&self, session_id: &str, path: &str) -> Result<(), String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
match client.remove_file(path).await {
Ok(()) => Ok(()),
Err(_) => client
.remove_dir(path)
.await
.map_err(|e| format!("SFTP delete '{}' failed: {}", path, e)),
}
}
/// Rename (or move) a remote path.
pub async fn rename(
&self,
session_id: &str,
old_path: &str,
new_path: &str,
) -> Result<(), String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
client
.rename(old_path, new_path)
.await
.map_err(|e| format!("SFTP rename '{}' → '{}' failed: {}", old_path, new_path, e))
}
/// Stat a single remote path and return its metadata as a `FileEntry`.
pub async fn stat(&self, session_id: &str, path: &str) -> Result<FileEntry, String> {
let arc = self.get_client(session_id)?;
let client = arc.lock().await;
let meta = client
.metadata(path)
.await
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
// Extract the file name from the path.
let name = path
.rsplit('/')
.next()
.unwrap_or(path)
.to_string();
Ok(FileEntry {
name,
path: path.to_string(),
size: meta.size.unwrap_or(0),
is_dir: meta.is_dir(),
permissions: format_permissions(meta.permissions),
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
})
}
// ── private helpers ───────────────────────────────────────────────────────
fn get_client(
&self,
session_id: &str,
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
self.clients
.get(session_id)
.map(|r| r.clone())
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
}
}