Skip to content

Implement SFTP server handler #132

@inureyes

Description

@inureyes

Summary

Implement the SFTP server subsystem using russh-sftp library. This enables file transfer operations through the SFTP protocol.

Parent Epic

Implementation Details

1. SFTP Server Handler

// src/server/sftp.rs
use russh_sftp::protocol::{
    FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// SFTP server handler
pub struct SftpHandler {
    /// Current user info
    user_info: UserInfo,
    /// Root directory for SFTP (chroot-like)
    root_dir: PathBuf,
    /// Open file handles
    handles: HashMap<String, OpenHandle>,
    /// Handle counter
    handle_counter: u64,
    /// Audit logger (optional)
    audit: Option<Arc<dyn AuditExporter>>,
    /// File transfer filter (optional)
    filter: Option<Arc<dyn TransferFilter>>,
}

enum OpenHandle {
    File {
        file: tokio::fs::File,
        path: PathBuf,
        flags: OpenFlags,
    },
    Dir {
        path: PathBuf,
        entries: Vec<DirEntry>,
        position: usize,
    },
}

impl SftpHandler {
    pub fn new(
        user_info: UserInfo,
        root_dir: Option<PathBuf>,
        audit: Option<Arc<dyn AuditExporter>>,
        filter: Option<Arc<dyn TransferFilter>>,
    ) -> Self {
        let root_dir = root_dir.unwrap_or_else(|| PathBuf::from("/"));
        Self {
            user_info,
            root_dir,
            handles: HashMap::new(),
            handle_counter: 0,
            audit,
            filter,
        }
    }

    /// Resolve path within root (prevent path traversal)
    fn resolve_path(&self, path: &str) -> Result<PathBuf, Status> {
        let path = Path::new(path);
        
        // Normalize and join with root
        let resolved = if path.is_absolute() {
            self.root_dir.join(path.strip_prefix("/").unwrap_or(path))
        } else {
            self.root_dir.join(path)
        };

        // Canonicalize and verify within root
        let canonical = resolved.canonicalize()
            .map_err(|_| Status {
                code: StatusCode::NoSuchFile,
                message: "Path not found".into(),
                language_tag: "en".into(),
            })?;

        if !canonical.starts_with(&self.root_dir) {
            return Err(Status {
                code: StatusCode::PermissionDenied,
                message: "Access denied".into(),
                language_tag: "en".into(),
            });
        }

        Ok(canonical)
    }

    /// Generate a new handle ID
    fn new_handle(&mut self) -> String {
        self.handle_counter += 1;
        format!("h{}", self.handle_counter)
    }

    /// Check filter for file operation
    fn check_filter(&self, path: &Path, operation: Operation) -> Result<(), Status> {
        if let Some(ref filter) = self.filter {
            match filter.check(path, operation, &self.user_info.username) {
                FilterResult::Allow => Ok(()),
                FilterResult::Deny => Err(Status {
                    code: StatusCode::PermissionDenied,
                    message: "Operation denied by filter".into(),
                    language_tag: "en".into(),
                }),
                FilterResult::Log => {
                    // Log but allow
                    tracing::info!("Filter log: {:?} on {:?}", operation, path);
                    Ok(())
                }
            }
        } else {
            Ok(())
        }
    }

    /// Log audit event
    async fn audit_log(&self, event: AuditEvent) {
        if let Some(ref audit) = self.audit {
            if let Err(e) = audit.export(event).await {
                tracing::warn!("Failed to log audit event: {}", e);
            }
        }
    }
}

#[async_trait]
impl russh_sftp::server::Handler for SftpHandler {
    type Error = anyhow::Error;

    /// Handle SFTP version negotiation
    async fn init(&mut self, version: u32, extensions: HashMap<String, String>) -> Result<Version, Self::Error> {
        tracing::debug!("SFTP init: version {}", version);
        Ok(Version::new())
    }

    /// Open a file
    async fn open(
        &mut self,
        id: u32,
        filename: String,
        pflags: OpenFlags,
        attrs: FileAttributes,
    ) -> Result<Handle, Self::Error> {
        let path = self.resolve_path(&filename)?;
        
        // Check filter
        let operation = if pflags.contains(OpenFlags::WRITE) {
            Operation::Upload
        } else {
            Operation::Download
        };
        self.check_filter(&path, operation)?;

        // Build open options
        let mut opts = tokio::fs::OpenOptions::new();
        if pflags.contains(OpenFlags::READ) {
            opts.read(true);
        }
        if pflags.contains(OpenFlags::WRITE) {
            opts.write(true);
        }
        if pflags.contains(OpenFlags::CREATE) {
            opts.create(true);
        }
        if pflags.contains(OpenFlags::TRUNCATE) {
            opts.truncate(true);
        }
        if pflags.contains(OpenFlags::APPEND) {
            opts.append(true);
        }

        let file = opts.open(&path).await
            .map_err(|e| self.io_to_status(e))?;

        let handle_id = self.new_handle();
        self.handles.insert(handle_id.clone(), OpenHandle::File {
            file,
            path: path.clone(),
            flags: pflags,
        });

        // Audit log
        self.audit_log(AuditEvent {
            event_type: if pflags.contains(OpenFlags::WRITE) {
                EventType::FileOpenWrite
            } else {
                EventType::FileOpenRead
            },
            user: self.user_info.username.clone(),
            path: Some(path),
            ..Default::default()
        }).await;

        Ok(Handle { id, handle: handle_id })
    }

    /// Read from file
    async fn read(
        &mut self,
        id: u32,
        handle: String,
        offset: u64,
        len: u32,
    ) -> Result<russh_sftp::protocol::Data, Self::Error> {
        let file = match self.handles.get_mut(&handle) {
            Some(OpenHandle::File { file, .. }) => file,
            _ => return Err(self.invalid_handle()),
        };

        use tokio::io::{AsyncReadExt, AsyncSeekExt};
        file.seek(std::io::SeekFrom::Start(offset)).await?;
        
        let mut buffer = vec![0u8; len as usize];
        let n = file.read(&mut buffer).await?;
        buffer.truncate(n);

        if n == 0 {
            return Err(anyhow::anyhow!("EOF"));
        }

        Ok(russh_sftp::protocol::Data { id, data: buffer })
    }

    /// Write to file
    async fn write(
        &mut self,
        id: u32,
        handle: String,
        offset: u64,
        data: Vec<u8>,
    ) -> Result<Status, Self::Error> {
        let (file, path) = match self.handles.get_mut(&handle) {
            Some(OpenHandle::File { file, path, .. }) => (file, path.clone()),
            _ => return Ok(self.invalid_handle_status()),
        };

        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
        file.seek(std::io::SeekFrom::Start(offset)).await?;
        file.write_all(&data).await?;

        // Audit log
        self.audit_log(AuditEvent {
            event_type: EventType::FileWrite,
            user: self.user_info.username.clone(),
            path: Some(path),
            bytes: Some(data.len() as u64),
            ..Default::default()
        }).await;

        Ok(Status {
            code: StatusCode::Ok,
            message: "".into(),
            language_tag: "en".into(),
        })
    }

    /// Close file handle
    async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
        if let Some(h) = self.handles.remove(&handle) {
            match h {
                OpenHandle::File { path, flags, .. } => {
                    self.audit_log(AuditEvent {
                        event_type: EventType::FileClose,
                        user: self.user_info.username.clone(),
                        path: Some(path),
                        ..Default::default()
                    }).await;
                }
                _ => {}
            }
        }

        Ok(Status {
            code: StatusCode::Ok,
            message: "".into(),
            language_tag: "en".into(),
        })
    }

    /// Open directory
    async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
        let resolved = self.resolve_path(&path)?;
        
        let mut entries = Vec::new();
        let mut dir = tokio::fs::read_dir(&resolved).await
            .map_err(|e| self.io_to_status(e))?;

        while let Some(entry) = dir.next_entry().await? {
            entries.push(entry);
        }

        let handle_id = self.new_handle();
        self.handles.insert(handle_id.clone(), OpenHandle::Dir {
            path: resolved,
            entries: entries.into_iter().map(|e| e.into()).collect(),
            position: 0,
        });

        Ok(Handle { id, handle: handle_id })
    }

    /// Read directory entries
    async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
        let (entries, position) = match self.handles.get_mut(&handle) {
            Some(OpenHandle::Dir { entries, position, .. }) => (entries, position),
            _ => return Err(self.invalid_handle()),
        };

        if *position >= entries.len() {
            return Err(anyhow::anyhow!("EOF"));
        }

        // Return batch of entries
        let batch_size = 100;
        let end = (*position + batch_size).min(entries.len());
        let batch: Vec<_> = entries[*position..end]
            .iter()
            .map(|e| e.to_sftp_name())
            .collect();
        
        *position = end;

        Ok(Name { id, files: batch })
    }

    /// Get file attributes
    async fn stat(&mut self, id: u32, path: String) -> Result<FileAttributes, Self::Error> {
        let resolved = self.resolve_path(&path)?;
        let metadata = tokio::fs::metadata(&resolved).await
            .map_err(|e| self.io_to_status(e))?;
        Ok(self.metadata_to_attrs(&metadata))
    }

    /// Get real path
    async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
        let resolved = self.resolve_path(&path)?;
        let name = resolved.to_string_lossy().to_string();
        
        Ok(Name {
            id,
            files: vec![russh_sftp::protocol::File {
                filename: name,
                longname: "".into(),
                attrs: FileAttributes::default(),
            }],
        })
    }

    // ... implement other SFTP operations (mkdir, rmdir, remove, rename, etc.)
}

2. Integrate with SSH Handler

// Update src/server/handler.rs
impl Handler for SshHandler {
    async fn subsystem_request(
        &mut self,
        channel_id: ChannelId,
        name: &str,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if name != "sftp" {
            session.channel_failure(channel_id)?;
            return Ok(());
        }

        if !self.config.sftp.enabled {
            tracing::debug!("SFTP disabled, rejecting request");
            session.channel_failure(channel_id)?;
            return Ok(());
        }

        let user = self.user.as_ref()
            .ok_or_else(|| anyhow::anyhow!("No authenticated user"))?;
        let user_info = self.config.auth_provider
            .get_user_info(user).await?
            .ok_or_else(|| anyhow::anyhow!("User not found"))?;

        let channel = self.channels.get(&channel_id)
            .ok_or_else(|| anyhow::anyhow!("Channel not found"))?;

        session.channel_success(channel_id)?;

        // Create SFTP handler
        let sftp_handler = SftpHandler::new(
            user_info,
            self.config.sftp.root.clone(),
            self.config.audit.clone(),
            self.config.filter.clone(),
        );

        // Run SFTP server
        let stream = channel.into_stream();
        russh_sftp::server::run(stream, sftp_handler).await;

        Ok(())
    }
}

Files to Create/Modify

File Action
src/server/sftp.rs Create - SFTP handler
src/server/handler.rs Modify - Integrate subsystem_request
src/server/mod.rs Modify - Add sftp module

Testing Requirements

  1. Unit test: Path resolution and traversal prevention
  2. Integration test: File upload via sftp client
  3. Integration test: File download via sftp client
  4. Integration test: Directory listing
  5. Integration test: File operations (rename, delete)
# Test SFTP connection
sftp -P 2222 testuser@localhost

# Test file operations
sftp> put local.txt
sftp> get remote.txt
sftp> ls -la
sftp> mkdir testdir

Acceptance Criteria

  • SftpHandler implements russh_sftp::server::Handler
  • File open/read/write/close operations
  • Directory operations (opendir, readdir, mkdir, rmdir)
  • Path operations (stat, realpath, rename, remove)
  • Path traversal prevention
  • Optional root directory (chroot-like)
  • Filter integration hooks
  • Audit logging hooks
  • Tests passing

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions