diff --git a/src/januskey/src/backend.rs b/src/januskey/src/backend.rs new file mode 100644 index 0000000..411539c --- /dev/null +++ b/src/januskey/src/backend.rs @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: MIT OR AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +// +// FileBackend: Abstraction for filesystem operations across local and remote targets +// Enables JanusKey to work with SSH/SFTP, S3, WebDAV, and other protocols + +use crate::error::{JanusError, Result}; +use crate::metadata::FileMetadata; +use std::path::{Path, PathBuf}; + +/// Abstraction over filesystem operations for local and remote targets +pub trait FileBackend: Send + Sync { + /// Get the backend type identifier + fn backend_type(&self) -> &'static str; + + /// Check if a path exists + fn exists(&self, path: &Path) -> Result; + + /// Check if path is a file + fn is_file(&self, path: &Path) -> Result; + + /// Check if path is a directory + fn is_dir(&self, path: &Path) -> Result; + + /// Check if path is a symlink + fn is_symlink(&self, path: &Path) -> Result; + + /// Read file contents + fn read(&self, path: &Path) -> Result>; + + /// Write file contents + fn write(&self, path: &Path, content: &[u8]) -> Result<()>; + + /// Delete a file + fn remove_file(&self, path: &Path) -> Result<()>; + + /// Delete an empty directory + fn remove_dir(&self, path: &Path) -> Result<()>; + + /// Delete a directory and all contents + fn remove_dir_all(&self, path: &Path) -> Result<()>; + + /// Create a directory + fn create_dir(&self, path: &Path) -> Result<()>; + + /// Create directory and all parents + fn create_dir_all(&self, path: &Path) -> Result<()>; + + /// Rename/move a file or directory + fn rename(&self, from: &Path, to: &Path) -> Result<()>; + + /// Copy a file + fn copy(&self, from: &Path, to: &Path) -> Result; + + /// Get file metadata + fn metadata(&self, path: &Path) -> Result; + + /// Get symlink metadata (don't follow symlinks) + fn symlink_metadata(&self, path: &Path) -> Result; + + /// Read symlink target + fn read_link(&self, path: &Path) -> Result; + + /// Create a symbolic link + fn symlink(&self, target: &Path, link: &Path) -> Result<()>; + + /// Set file permissions (Unix mode) + fn set_permissions(&self, path: &Path, mode: u32) -> Result<()>; + + /// Set file modification time + fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> Result<()>; + + /// Truncate file to specified size + fn truncate(&self, path: &Path, size: u64) -> Result<()>; + + /// Append content to file + fn append(&self, path: &Path, content: &[u8]) -> Result<()>; + + /// List directory contents + fn read_dir(&self, path: &Path) -> Result>; + + /// Walk directory tree recursively + fn walk_dir(&self, path: &Path) -> Result>; + + /// Securely overwrite file content (for RMO obliteration) + fn secure_overwrite(&self, path: &Path, passes: usize) -> Result<()>; +} + +/// Local filesystem backend (default) +pub struct LocalBackend; + +impl LocalBackend { + pub fn new() -> Self { + Self + } +} + +impl Default for LocalBackend { + fn default() -> Self { + Self::new() + } +} + +impl FileBackend for LocalBackend { + fn backend_type(&self) -> &'static str { + "local" + } + + fn exists(&self, path: &Path) -> Result { + Ok(path.exists()) + } + + fn is_file(&self, path: &Path) -> Result { + Ok(path.is_file()) + } + + fn is_dir(&self, path: &Path) -> Result { + Ok(path.is_dir()) + } + + fn is_symlink(&self, path: &Path) -> Result { + Ok(path.symlink_metadata()?.file_type().is_symlink()) + } + + fn read(&self, path: &Path) -> Result> { + std::fs::read(path).map_err(Into::into) + } + + fn write(&self, path: &Path, content: &[u8]) -> Result<()> { + std::fs::write(path, content).map_err(Into::into) + } + + fn remove_file(&self, path: &Path) -> Result<()> { + std::fs::remove_file(path).map_err(Into::into) + } + + fn remove_dir(&self, path: &Path) -> Result<()> { + std::fs::remove_dir(path).map_err(Into::into) + } + + fn remove_dir_all(&self, path: &Path) -> Result<()> { + std::fs::remove_dir_all(path).map_err(Into::into) + } + + fn create_dir(&self, path: &Path) -> Result<()> { + std::fs::create_dir(path).map_err(Into::into) + } + + fn create_dir_all(&self, path: &Path) -> Result<()> { + std::fs::create_dir_all(path).map_err(Into::into) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<()> { + std::fs::rename(from, to).map_err(Into::into) + } + + fn copy(&self, from: &Path, to: &Path) -> Result { + std::fs::copy(from, to).map_err(Into::into) + } + + fn metadata(&self, path: &Path) -> Result { + FileMetadata::from_path(path) + } + + fn symlink_metadata(&self, path: &Path) -> Result { + FileMetadata::from_path(path) + } + + fn read_link(&self, path: &Path) -> Result { + std::fs::read_link(path).map_err(Into::into) + } + + #[cfg(unix)] + fn symlink(&self, target: &Path, link: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, link).map_err(Into::into) + } + + #[cfg(not(unix))] + fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> { + Err(JanusError::OperationFailed( + "Symlinks not supported on this platform".to_string(), + )) + } + + #[cfg(unix)] + fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(mode); + std::fs::set_permissions(path, perms).map_err(Into::into) + } + + #[cfg(not(unix))] + fn set_permissions(&self, _path: &Path, _mode: u32) -> Result<()> { + // Windows doesn't use Unix permissions + Ok(()) + } + + fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> Result<()> { + let ft = filetime::FileTime::from_system_time(mtime); + filetime::set_file_mtime(path, ft).map_err(|e| JanusError::IoError(e.to_string())) + } + + fn truncate(&self, path: &Path, size: u64) -> Result<()> { + let file = std::fs::OpenOptions::new().write(true).open(path)?; + file.set_len(size).map_err(Into::into) + } + + fn append(&self, path: &Path, content: &[u8]) -> Result<()> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + file.write_all(content).map_err(Into::into) + } + + fn read_dir(&self, path: &Path) -> Result> { + let entries: std::io::Result> = std::fs::read_dir(path)? + .map(|e| e.map(|e| e.path())) + .collect(); + entries.map_err(Into::into) + } + + fn walk_dir(&self, path: &Path) -> Result> { + let mut paths = Vec::new(); + for entry in walkdir::WalkDir::new(path).into_iter().filter_map(|e| e.ok()) { + paths.push(entry.path().to_path_buf()); + } + Ok(paths) + } + + fn secure_overwrite(&self, path: &Path, passes: usize) -> Result<()> { + use rand::RngCore; + use std::io::{Seek, SeekFrom, Write}; + + let metadata = std::fs::metadata(path)?; + let size = metadata.len() as usize; + + let mut file = std::fs::OpenOptions::new().write(true).open(path)?; + + for pass in 0..passes { + file.seek(SeekFrom::Start(0))?; + + let pattern: Vec = match pass % 3 { + 0 => vec![0x00; size], // All zeros + 1 => vec![0xFF; size], // All ones + _ => { + let mut buf = vec![0u8; size]; + rand::thread_rng().fill_bytes(&mut buf); + buf + } + }; + + file.write_all(&pattern)?; + file.sync_all()?; + } + + Ok(()) + } +} + +/// URI parser for remote backends +#[derive(Debug, Clone)] +pub struct RemoteUri { + pub protocol: String, + pub user: Option, + pub host: String, + pub port: Option, + pub path: PathBuf, +} + +impl RemoteUri { + /// Parse a URI string like "ssh://user@host:port/path" or "s3://bucket/key" + pub fn parse(uri: &str) -> Option { + // Handle protocol prefix + let (protocol, rest) = uri.split_once("://")?; + + // Handle user@host:port/path + let (authority, path) = if let Some(idx) = rest.find('/') { + (&rest[..idx], &rest[idx..]) + } else { + (rest, "/") + }; + + let (user_host, port) = if let Some(idx) = authority.rfind(':') { + let port_str = &authority[idx + 1..]; + if let Ok(p) = port_str.parse::() { + (&authority[..idx], Some(p)) + } else { + (authority, None) + } + } else { + (authority, None) + }; + + let (user, host) = if let Some(idx) = user_host.find('@') { + (Some(user_host[..idx].to_string()), &user_host[idx + 1..]) + } else { + (None, user_host) + }; + + Some(Self { + protocol: protocol.to_string(), + user, + host: host.to_string(), + port, + path: PathBuf::from(path), + }) + } +} + +/// Configuration for SSH backend +#[derive(Debug, Clone)] +pub struct SshConfig { + pub host: String, + pub port: u16, + pub user: String, + pub key_path: Option, + pub password: Option, +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + host: "localhost".to_string(), + port: 22, + user: whoami::username(), + key_path: None, + password: None, + } + } +} + +/// Configuration for S3 backend +#[derive(Debug, Clone)] +pub struct S3Config { + pub bucket: String, + pub region: String, + pub endpoint: Option, + pub access_key: Option, + pub secret_key: Option, +} + +impl Default for S3Config { + fn default() -> Self { + Self { + bucket: String::new(), + region: "us-east-1".to_string(), + endpoint: None, + access_key: None, + secret_key: None, + } + } +} + +/// Factory for creating backends from URIs +pub struct BackendFactory; + +impl BackendFactory { + /// Create a backend from a path or URI + /// + /// Supported formats: + /// - Local path: `/path/to/file` or `./relative` + /// - SSH/SFTP: `ssh://user@host:port/path` or `sftp://...` + /// - S3: `s3://bucket/key` + pub fn from_uri(uri: &str) -> Result> { + if uri.contains("://") { + let parsed = RemoteUri::parse(uri) + .ok_or_else(|| JanusError::OperationFailed(format!("Invalid URI: {}", uri)))?; + + match parsed.protocol.as_str() { + "ssh" | "sftp" => { + // SSH backend would be instantiated here + // For now, return an error indicating it needs to be enabled + Err(JanusError::OperationFailed( + "SSH backend not yet implemented. Use 'ssh' feature flag.".to_string(), + )) + } + "s3" => { + // S3 backend would be instantiated here + Err(JanusError::OperationFailed( + "S3 backend not yet implemented. Use 's3' feature flag.".to_string(), + )) + } + proto => Err(JanusError::OperationFailed(format!( + "Unknown protocol: {}", + proto + ))), + } + } else { + // Local path + Ok(Box::new(LocalBackend::new())) + } + } + + /// Create a local backend + pub fn local() -> Box { + Box::new(LocalBackend::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_local_backend_basic_ops() { + let tmp = TempDir::new().unwrap(); + let backend = LocalBackend::new(); + + let test_file = tmp.path().join("test.txt"); + + // Write + backend.write(&test_file, b"hello world").unwrap(); + assert!(backend.exists(&test_file).unwrap()); + assert!(backend.is_file(&test_file).unwrap()); + + // Read + let content = backend.read(&test_file).unwrap(); + assert_eq!(content, b"hello world"); + + // Append + backend.append(&test_file, b" appended").unwrap(); + let content = backend.read(&test_file).unwrap(); + assert_eq!(content, b"hello world appended"); + + // Truncate + backend.truncate(&test_file, 5).unwrap(); + let content = backend.read(&test_file).unwrap(); + assert_eq!(content, b"hello"); + + // Delete + backend.remove_file(&test_file).unwrap(); + assert!(!backend.exists(&test_file).unwrap()); + } + + #[test] + fn test_local_backend_directory_ops() { + let tmp = TempDir::new().unwrap(); + let backend = LocalBackend::new(); + + let test_dir = tmp.path().join("subdir"); + + // Create directory + backend.create_dir(&test_dir).unwrap(); + assert!(backend.is_dir(&test_dir).unwrap()); + + // Create nested directories + let nested = test_dir.join("a/b/c"); + backend.create_dir_all(&nested).unwrap(); + assert!(backend.is_dir(&nested).unwrap()); + + // Remove directory + backend.remove_dir(&nested).unwrap(); + assert!(!backend.exists(&nested).unwrap()); + } + + #[test] + fn test_uri_parsing() { + let uri = RemoteUri::parse("ssh://user@example.com:2222/home/user/file.txt").unwrap(); + assert_eq!(uri.protocol, "ssh"); + assert_eq!(uri.user, Some("user".to_string())); + assert_eq!(uri.host, "example.com"); + assert_eq!(uri.port, Some(2222)); + assert_eq!(uri.path, PathBuf::from("/home/user/file.txt")); + + let uri = RemoteUri::parse("s3://my-bucket/path/to/object").unwrap(); + assert_eq!(uri.protocol, "s3"); + assert_eq!(uri.host, "my-bucket"); + assert_eq!(uri.path, PathBuf::from("/path/to/object")); + } + + #[test] + fn test_backend_factory() { + // Local path should work + let backend = BackendFactory::from_uri("/tmp/test").unwrap(); + assert_eq!(backend.backend_type(), "local"); + + // SSH should fail gracefully (not implemented) + let result = BackendFactory::from_uri("ssh://user@host/path"); + assert!(result.is_err()); + } + + #[test] + fn test_secure_overwrite() { + let tmp = TempDir::new().unwrap(); + let backend = LocalBackend::new(); + + let test_file = tmp.path().join("secret.txt"); + backend.write(&test_file, b"sensitive data here").unwrap(); + + // Secure overwrite with 3 passes + backend.secure_overwrite(&test_file, 3).unwrap(); + + // File should still exist but content should be overwritten + assert!(backend.exists(&test_file).unwrap()); + let content = backend.read(&test_file).unwrap(); + assert_ne!(content, b"sensitive data here"); + } +} diff --git a/src/januskey/src/backend_s3.rs b/src/januskey/src/backend_s3.rs new file mode 100644 index 0000000..c7b18c5 --- /dev/null +++ b/src/januskey/src/backend_s3.rs @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT OR AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +// +// S3 Backend: Remote file operations on S3-compatible storage +// Requires 's3' feature flag + +use crate::backend::{FileBackend, S3Config}; +use crate::error::{JanusError, Result}; +use crate::metadata::FileMetadata; +use chrono::{DateTime, Utc}; +use std::path::{Path, PathBuf}; + +/// S3 backend for cloud storage operations +/// +/// Maps filesystem operations to S3 concepts: +/// - Files -> Objects +/// - Directories -> Common prefixes (virtual directories) +/// - Paths -> Object keys with '/' separators +pub struct S3Backend { + config: S3Config, + // In a real implementation, this would hold an S3 client + // e.g., aws_sdk_s3::Client or rusoto_s3::S3Client +} + +impl S3Backend { + /// Create a new S3 backend with the given configuration + pub fn new(config: S3Config) -> Result { + if config.bucket.is_empty() { + return Err(JanusError::OperationFailed( + "S3 bucket name is required".to_string(), + )); + } + + // In a real implementation, we would initialize the S3 client here + // This is a stub that shows the structure + + Ok(Self { config }) + } + + /// Convert a path to an S3 key + fn path_to_key(&self, path: &Path) -> String { + let key = path.to_string_lossy(); + // Remove leading slash for S3 keys + key.trim_start_matches('/').to_string() + } + + /// Convert an S3 key to a path + fn key_to_path(&self, key: &str) -> PathBuf { + PathBuf::from(format!("/{}", key)) + } + + /// Check if a key represents a "directory" (has trailing slash or has children) + fn is_directory_key(&self, key: &str) -> bool { + key.ends_with('/') || key.is_empty() + } +} + +impl FileBackend for S3Backend { + fn backend_type(&self) -> &'static str { + "s3" + } + + fn exists(&self, path: &Path) -> Result { + let _key = self.path_to_key(path); + // S3 HeadObject to check existence + // For now, return a stub error + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn is_file(&self, path: &Path) -> Result { + let key = self.path_to_key(path); + // An S3 object is a "file" if it doesn't end with / + if self.exists(path)? { + Ok(!self.is_directory_key(&key)) + } else { + Ok(false) + } + } + + fn is_dir(&self, path: &Path) -> Result { + let key = self.path_to_key(path); + // An S3 "directory" is either: + // 1. An empty string (root) + // 2. A key ending with / + // 3. A prefix that has child objects + Ok(self.is_directory_key(&key) || key.is_empty()) + } + + fn is_symlink(&self, _path: &Path) -> Result { + // S3 doesn't support symlinks + Ok(false) + } + + fn read(&self, path: &Path) -> Result> { + let _key = self.path_to_key(path); + // S3 GetObject + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn write(&self, path: &Path, _content: &[u8]) -> Result<()> { + let _key = self.path_to_key(path); + // S3 PutObject + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn remove_file(&self, path: &Path) -> Result<()> { + let _key = self.path_to_key(path); + // S3 DeleteObject + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn remove_dir(&self, path: &Path) -> Result<()> { + let key = self.path_to_key(path); + // S3 doesn't have real directories + // We can delete the directory marker object if it exists + let dir_key = if key.ends_with('/') { + key + } else { + format!("{}/", key) + }; + let _dir_key = dir_key; + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn remove_dir_all(&self, path: &Path) -> Result<()> { + let _key = self.path_to_key(path); + // S3 DeleteObjects (batch delete all objects with prefix) + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn create_dir(&self, path: &Path) -> Result<()> { + let key = self.path_to_key(path); + // Create a directory marker object (empty object with trailing /) + let _dir_key = if key.ends_with('/') { + key + } else { + format!("{}/", key) + }; + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn create_dir_all(&self, path: &Path) -> Result<()> { + // S3 doesn't need to create parent directories + self.create_dir(path) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<()> { + // S3 doesn't have rename - copy then delete + self.copy(from, to)?; + self.remove_file(from) + } + + fn copy(&self, from: &Path, to: &Path) -> Result { + let _from_key = self.path_to_key(from); + let _to_key = self.path_to_key(to); + // S3 CopyObject + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn metadata(&self, path: &Path) -> Result { + let _key = self.path_to_key(path); + // S3 HeadObject to get metadata + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn symlink_metadata(&self, path: &Path) -> Result { + // S3 doesn't support symlinks, return regular metadata + self.metadata(path) + } + + fn read_link(&self, _path: &Path) -> Result { + Err(JanusError::OperationFailed( + "S3 doesn't support symlinks".to_string(), + )) + } + + fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> { + Err(JanusError::OperationFailed( + "S3 doesn't support symlinks".to_string(), + )) + } + + fn set_permissions(&self, _path: &Path, _mode: u32) -> Result<()> { + // S3 uses ACLs, not Unix permissions + // This could be mapped to S3 ACLs in a full implementation + Ok(()) + } + + fn set_mtime(&self, path: &Path, _mtime: std::time::SystemTime) -> Result<()> { + // S3 doesn't support setting mtime directly + // Would need to copy object with new metadata + let _key = self.path_to_key(path); + Err(JanusError::OperationFailed( + "S3 doesn't support setting modification time".to_string(), + )) + } + + fn truncate(&self, path: &Path, size: u64) -> Result<()> { + // S3 doesn't support truncate - read, truncate, write + let content = self.read(path)?; + let truncated: Vec = content.into_iter().take(size as usize).collect(); + self.write(path, &truncated) + } + + fn append(&self, path: &Path, content: &[u8]) -> Result<()> { + // S3 doesn't support append - read, append, write + let mut existing = self.read(path).unwrap_or_default(); + existing.extend_from_slice(content); + self.write(path, &existing) + } + + fn read_dir(&self, path: &Path) -> Result> { + let _key = self.path_to_key(path); + // S3 ListObjectsV2 with prefix and delimiter + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn walk_dir(&self, path: &Path) -> Result> { + let _key = self.path_to_key(path); + // S3 ListObjectsV2 with prefix (no delimiter for recursive) + Err(JanusError::OperationFailed( + "S3 backend not fully implemented".to_string(), + )) + } + + fn secure_overwrite(&self, path: &Path, passes: usize) -> Result<()> { + use rand::RngCore; + + // S3 doesn't support in-place overwrite + // We can write random data multiple times, but S3 versioning + // might preserve old versions + + let metadata = self.metadata(path)?; + let size = metadata.size as usize; + + for pass in 0..passes { + let pattern: Vec = match pass % 3 { + 0 => vec![0x00; size], + 1 => vec![0xFF; size], + _ => { + let mut buf = vec![0u8; size]; + rand::thread_rng().fill_bytes(&mut buf); + buf + } + }; + + self.write(path, &pattern)?; + } + + // Note: For true secure deletion on S3, you need to: + // 1. Delete all object versions + // 2. Delete any delete markers + // 3. Consider if the bucket has MFA delete enabled + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_path_to_key() { + let config = S3Config { + bucket: "test-bucket".to_string(), + ..Default::default() + }; + let backend = S3Backend::new(config).unwrap(); + + assert_eq!(backend.path_to_key(Path::new("/foo/bar.txt")), "foo/bar.txt"); + assert_eq!(backend.path_to_key(Path::new("foo/bar.txt")), "foo/bar.txt"); + assert_eq!(backend.path_to_key(Path::new("/")), ""); + } + + #[test] + fn test_directory_key_detection() { + let config = S3Config { + bucket: "test-bucket".to_string(), + ..Default::default() + }; + let backend = S3Backend::new(config).unwrap(); + + assert!(backend.is_directory_key("foo/")); + assert!(backend.is_directory_key("")); + assert!(!backend.is_directory_key("foo/bar.txt")); + } +} diff --git a/src/januskey/src/backend_ssh.rs b/src/januskey/src/backend_ssh.rs new file mode 100644 index 0000000..1db9a85 --- /dev/null +++ b/src/januskey/src/backend_ssh.rs @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: MIT OR AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +// +// SSH/SFTP Backend: Remote file operations over SSH +// Requires 'ssh' feature flag + +use crate::backend::{FileBackend, SshConfig}; +use crate::error::{JanusError, Result}; +use crate::metadata::FileMetadata; +use chrono::{DateTime, Utc}; +use ssh2::{FileStat, Session, Sftp}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// SSH/SFTP backend for remote file operations +pub struct SshBackend { + config: SshConfig, + session: Arc>, +} + +impl SshBackend { + /// Create a new SSH backend with the given configuration + pub fn new(config: SshConfig) -> Result { + let session = Self::connect(&config)?; + Ok(Self { + config, + session: Arc::new(Mutex::new(session)), + }) + } + + /// Connect to the SSH server + fn connect(config: &SshConfig) -> Result { + let addr = format!("{}:{}", config.host, config.port); + let tcp = TcpStream::connect(&addr).map_err(|e| { + JanusError::IoError(format!("Failed to connect to {}: {}", addr, e)) + })?; + + tcp.set_read_timeout(Some(Duration::from_secs(30)))?; + tcp.set_write_timeout(Some(Duration::from_secs(30)))?; + + let mut session = Session::new() + .map_err(|e| JanusError::IoError(format!("Failed to create SSH session: {}", e)))?; + + session.set_tcp_stream(tcp); + session + .handshake() + .map_err(|e| JanusError::IoError(format!("SSH handshake failed: {}", e)))?; + + // Authenticate + if let Some(ref key_path) = config.key_path { + session + .userauth_pubkey_file(&config.user, None, key_path, config.password.as_deref()) + .map_err(|e| JanusError::IoError(format!("SSH key auth failed: {}", e)))?; + } else if let Some(ref password) = config.password { + session + .userauth_password(&config.user, password) + .map_err(|e| JanusError::IoError(format!("SSH password auth failed: {}", e)))?; + } else { + // Try SSH agent + let mut agent = session + .agent() + .map_err(|e| JanusError::IoError(format!("SSH agent failed: {}", e)))?; + agent + .connect() + .map_err(|e| JanusError::IoError(format!("SSH agent connect failed: {}", e)))?; + agent + .list_identities() + .map_err(|e| JanusError::IoError(format!("SSH agent list failed: {}", e)))?; + + let identities: Vec<_> = agent.identities().collect(); + let mut authenticated = false; + for identity in identities { + if let Ok(id) = identity { + if agent.userauth(&config.user, &id).is_ok() { + authenticated = true; + break; + } + } + } + if !authenticated { + return Err(JanusError::IoError( + "No SSH authentication method succeeded".to_string(), + )); + } + } + + if !session.authenticated() { + return Err(JanusError::IoError("SSH authentication failed".to_string())); + } + + Ok(session) + } + + /// Get an SFTP session + fn sftp(&self) -> Result { + let session = self.session.lock().map_err(|e| { + JanusError::IoError(format!("Failed to lock SSH session: {}", e)) + })?; + + session + .sftp() + .map_err(|e| JanusError::IoError(format!("Failed to create SFTP session: {}", e))) + } + + /// Convert FileStat to FileMetadata + fn stat_to_metadata(stat: &FileStat, path: &Path) -> FileMetadata { + let permissions = stat.perm.unwrap_or(0o644); + let size = stat.size.unwrap_or(0); + let mtime = stat.mtime.map(|t| { + DateTime::::from(UNIX_EPOCH + Duration::from_secs(t)) + }).unwrap_or_else(Utc::now); + + let uid = stat.uid.unwrap_or(0); + let gid = stat.gid.unwrap_or(0); + + FileMetadata { + permissions, + owner: uid.to_string(), + group: gid.to_string(), + size, + modified: mtime, + is_symlink: false, // SFTP stat follows symlinks + symlink_target: None, + } + } +} + +impl FileBackend for SshBackend { + fn backend_type(&self) -> &'static str { + "ssh" + } + + fn exists(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + match sftp.stat(path) { + Ok(_) => Ok(true), + Err(e) if e.code() == ssh2::ErrorCode::SFTP(2) => Ok(false), // No such file + Err(e) => Err(JanusError::IoError(format!("SFTP stat failed: {}", e))), + } + } + + fn is_file(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + match sftp.stat(path) { + Ok(stat) => Ok(stat.is_file()), + Err(e) if e.code() == ssh2::ErrorCode::SFTP(2) => Ok(false), + Err(e) => Err(JanusError::IoError(format!("SFTP stat failed: {}", e))), + } + } + + fn is_dir(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + match sftp.stat(path) { + Ok(stat) => Ok(stat.is_dir()), + Err(e) if e.code() == ssh2::ErrorCode::SFTP(2) => Ok(false), + Err(e) => Err(JanusError::IoError(format!("SFTP stat failed: {}", e))), + } + } + + fn is_symlink(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + match sftp.lstat(path) { + Ok(stat) => { + // Check if it's a symlink by comparing stat and lstat + if let Ok(target_stat) = sftp.stat(path) { + // If they differ, it's likely a symlink + Ok(stat.size != target_stat.size || stat.mtime != target_stat.mtime) + } else { + // If stat fails but lstat succeeds, it's a broken symlink + Ok(true) + } + } + Err(e) if e.code() == ssh2::ErrorCode::SFTP(2) => Ok(false), + Err(e) => Err(JanusError::IoError(format!("SFTP lstat failed: {}", e))), + } + } + + fn read(&self, path: &Path) -> Result> { + let sftp = self.sftp()?; + let mut file = sftp.open(path).map_err(|e| { + JanusError::IoError(format!("SFTP open failed for {}: {}", path.display(), e)) + })?; + + let mut content = Vec::new(); + file.read_to_end(&mut content).map_err(|e| { + JanusError::IoError(format!("SFTP read failed: {}", e)) + })?; + + Ok(content) + } + + fn write(&self, path: &Path, content: &[u8]) -> Result<()> { + let sftp = self.sftp()?; + let mut file = sftp + .create(path) + .map_err(|e| JanusError::IoError(format!("SFTP create failed: {}", e)))?; + + file.write_all(content) + .map_err(|e| JanusError::IoError(format!("SFTP write failed: {}", e)))?; + + Ok(()) + } + + fn remove_file(&self, path: &Path) -> Result<()> { + let sftp = self.sftp()?; + sftp.unlink(path) + .map_err(|e| JanusError::IoError(format!("SFTP unlink failed: {}", e))) + } + + fn remove_dir(&self, path: &Path) -> Result<()> { + let sftp = self.sftp()?; + sftp.rmdir(path) + .map_err(|e| JanusError::IoError(format!("SFTP rmdir failed: {}", e))) + } + + fn remove_dir_all(&self, path: &Path) -> Result<()> { + // Recursively remove directory contents + let entries = self.read_dir(path)?; + + for entry in entries { + if self.is_dir(&entry)? { + self.remove_dir_all(&entry)?; + } else { + self.remove_file(&entry)?; + } + } + + self.remove_dir(path) + } + + fn create_dir(&self, path: &Path) -> Result<()> { + let sftp = self.sftp()?; + sftp.mkdir(path, 0o755) + .map_err(|e| JanusError::IoError(format!("SFTP mkdir failed: {}", e))) + } + + fn create_dir_all(&self, path: &Path) -> Result<()> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + if !self.exists(¤t)? { + self.create_dir(¤t)?; + } + } + Ok(()) + } + + fn rename(&self, from: &Path, to: &Path) -> Result<()> { + let sftp = self.sftp()?; + sftp.rename(from, to, None) + .map_err(|e| JanusError::IoError(format!("SFTP rename failed: {}", e))) + } + + fn copy(&self, from: &Path, to: &Path) -> Result { + // SFTP doesn't have a native copy, so we read and write + let content = self.read(from)?; + let size = content.len() as u64; + self.write(to, &content)?; + Ok(size) + } + + fn metadata(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + let stat = sftp + .stat(path) + .map_err(|e| JanusError::IoError(format!("SFTP stat failed: {}", e)))?; + + Ok(Self::stat_to_metadata(&stat, path)) + } + + fn symlink_metadata(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + let stat = sftp + .lstat(path) + .map_err(|e| JanusError::IoError(format!("SFTP lstat failed: {}", e)))?; + + Ok(Self::stat_to_metadata(&stat, path)) + } + + fn read_link(&self, path: &Path) -> Result { + let sftp = self.sftp()?; + sftp.readlink(path) + .map_err(|e| JanusError::IoError(format!("SFTP readlink failed: {}", e))) + } + + fn symlink(&self, target: &Path, link: &Path) -> Result<()> { + let sftp = self.sftp()?; + sftp.symlink(target, link) + .map_err(|e| JanusError::IoError(format!("SFTP symlink failed: {}", e))) + } + + fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> { + let sftp = self.sftp()?; + let mut stat = FileStat::default(); + stat.perm = Some(mode); + + sftp.setstat(path, stat) + .map_err(|e| JanusError::IoError(format!("SFTP setstat failed: {}", e))) + } + + fn set_mtime(&self, path: &Path, mtime: SystemTime) -> Result<()> { + let sftp = self.sftp()?; + let duration = mtime + .duration_since(UNIX_EPOCH) + .map_err(|e| JanusError::IoError(format!("Invalid mtime: {}", e)))?; + + let mut stat = FileStat::default(); + stat.mtime = Some(duration.as_secs()); + + sftp.setstat(path, stat) + .map_err(|e| JanusError::IoError(format!("SFTP setstat failed: {}", e))) + } + + fn truncate(&self, path: &Path, size: u64) -> Result<()> { + // SFTP doesn't have truncate, so we read, truncate in memory, and write + let content = self.read(path)?; + let truncated: Vec = content.into_iter().take(size as usize).collect(); + self.write(path, &truncated) + } + + fn append(&self, path: &Path, content: &[u8]) -> Result<()> { + let sftp = self.sftp()?; + let mut file = sftp + .open_mode( + path, + ssh2::OpenFlags::WRITE | ssh2::OpenFlags::APPEND, + 0o644, + ssh2::OpenType::File, + ) + .map_err(|e| JanusError::IoError(format!("SFTP open for append failed: {}", e)))?; + + file.write_all(content) + .map_err(|e| JanusError::IoError(format!("SFTP append failed: {}", e))) + } + + fn read_dir(&self, path: &Path) -> Result> { + let sftp = self.sftp()?; + let dir = sftp + .readdir(path) + .map_err(|e| JanusError::IoError(format!("SFTP readdir failed: {}", e)))?; + + Ok(dir.into_iter().map(|(p, _)| p).collect()) + } + + fn walk_dir(&self, path: &Path) -> Result> { + let mut paths = vec![path.to_path_buf()]; + let mut result = Vec::new(); + + while let Some(current) = paths.pop() { + result.push(current.clone()); + if self.is_dir(¤t)? { + for entry in self.read_dir(¤t)? { + paths.push(entry); + } + } + } + + Ok(result) + } + + fn secure_overwrite(&self, path: &Path, passes: usize) -> Result<()> { + use rand::RngCore; + + // Get file size + let metadata = self.metadata(path)?; + let size = metadata.size as usize; + + for pass in 0..passes { + let pattern: Vec = match pass % 3 { + 0 => vec![0x00; size], + 1 => vec![0xFF; size], + _ => { + let mut buf = vec![0u8; size]; + rand::thread_rng().fill_bytes(&mut buf); + buf + } + }; + + self.write(path, &pattern)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + // SSH tests require a running SSH server, so they're disabled by default + // Run with: cargo test --features ssh -- --ignored + + #[test] + #[ignore] + fn test_ssh_backend_requires_server() { + // This test is a placeholder - real tests need an SSH server + println!("SSH backend tests require a running SSH server"); + } +}