Skip to content

Implement SCP server protocol #133

@inureyes

Description

@inureyes

Summary

Implement the SCP (Secure Copy Protocol) server to enable scp command file transfers. SCP runs as a regular command and uses a simple protocol over stdin/stdout.

Parent Epic

Background

SCP protocol is simpler than SFTP - it's essentially the scp program communicating over SSH. When a client runs scp file user@host:path, the SSH server receives an exec request for scp -t path (upload) or scp -f path (download).

Implementation Details

1. SCP Protocol Handler

// src/server/scp.rs
use tokio::io::{AsyncReadExt, AsyncWriteExt};

/// SCP mode
#[derive(Debug, Clone, Copy)]
pub enum ScpMode {
    /// Source mode (-f): Server sends files to client
    Source,
    /// Sink mode (-t): Server receives files from client
    Sink,
}

/// SCP server handler
pub struct ScpHandler {
    mode: ScpMode,
    path: PathBuf,
    recursive: bool,
    preserve_times: bool,
    user_info: UserInfo,
    root_dir: PathBuf,
    audit: Option<Arc<dyn AuditExporter>>,
    filter: Option<Arc<dyn TransferFilter>>,
}

impl ScpHandler {
    /// Parse SCP command arguments
    pub fn from_command(command: &str, user_info: UserInfo, config: &ScpConfig) -> Result<Self> {
        // Parse: scp [-r] [-p] [-d] (-t|-f) path
        let args: Vec<&str> = command.split_whitespace().collect();
        
        if args.is_empty() || args[0] != "scp" {
            anyhow::bail!("Not an SCP command");
        }

        let mut mode = None;
        let mut recursive = false;
        let mut preserve_times = false;
        let mut path = None;

        let mut i = 1;
        while i < args.len() {
            match args[i] {
                "-t" => mode = Some(ScpMode::Sink),
                "-f" => mode = Some(ScpMode::Source),
                "-r" => recursive = true,
                "-p" => preserve_times = true,
                "-d" => {} // Target is directory, ignored
                arg if !arg.starts_with('-') => {
                    path = Some(PathBuf::from(arg));
                }
                _ => {} // Ignore unknown options
            }
            i += 1;
        }

        let mode = mode.ok_or_else(|| anyhow::anyhow!("Missing -t or -f flag"))?;
        let path = path.ok_or_else(|| anyhow::anyhow!("Missing path"))?;

        Ok(Self {
            mode,
            path,
            recursive,
            preserve_times,
            user_info,
            root_dir: config.root.clone().unwrap_or_else(|| PathBuf::from("/")),
            audit: None,
            filter: None,
        })
    }

    /// Run SCP protocol
    pub async fn run<R, W>(&self, mut reader: R, mut writer: W) -> Result<()>
    where
        R: AsyncReadExt + Unpin,
        W: AsyncWriteExt + Unpin,
    {
        match self.mode {
            ScpMode::Sink => self.run_sink(&mut reader, &mut writer).await,
            ScpMode::Source => self.run_source(&mut reader, &mut writer).await,
        }
    }

    /// Sink mode: Receive files from client
    async fn run_sink<R, W>(&self, reader: &mut R, writer: &mut W) -> Result<()>
    where
        R: AsyncReadExt + Unpin,
        W: AsyncWriteExt + Unpin,
    {
        // Send ready signal
        writer.write_all(&[0]).await?;
        writer.flush().await?;

        let mut current_dir = self.resolve_path(&self.path)?;
        
        loop {
            // Read command byte
            let mut cmd = [0u8; 1];
            if reader.read_exact(&mut cmd).await.is_err() {
                break;
            }

            match cmd[0] {
                b'C' => {
                    // File: C<mode> <size> <filename>
                    self.receive_file(reader, writer, &current_dir).await?;
                }
                b'D' => {
                    // Directory: D<mode> 0 <dirname>
                    if self.recursive {
                        current_dir = self.enter_directory(reader, writer, &current_dir).await?;
                    } else {
                        self.send_error(writer, "Recursive mode not enabled").await?;
                    }
                }
                b'E' => {
                    // End of directory
                    if let Some(parent) = current_dir.parent() {
                        current_dir = parent.to_path_buf();
                    }
                    writer.write_all(&[0]).await?;
                }
                b'T' => {
                    // Preserve times: T<mtime> 0 <atime> 0
                    self.read_times(reader).await?;
                    writer.write_all(&[0]).await?;
                }
                b'\x01' | b'\x02' => {
                    // Error from client
                    let msg = self.read_line(reader).await?;
                    tracing::warn!("SCP client error: {}", msg);
                    break;
                }
                _ => {
                    self.send_error(writer, "Unknown command").await?;
                }
            }
        }

        Ok(())
    }

    /// Receive a single file
    async fn receive_file<R, W>(
        &self,
        reader: &mut R,
        writer: &mut W,
        target_dir: &Path,
    ) -> Result<()>
    where
        R: AsyncReadExt + Unpin,
        W: AsyncWriteExt + Unpin,
    {
        // Read header: C<mode> <size> <filename>\n
        let header = self.read_line(reader).await?;
        let parts: Vec<&str> = header.splitn(3, ' ').collect();
        
        if parts.len() != 3 {
            self.send_error(writer, "Invalid file header").await?;
            return Ok(());
        }

        let mode = u32::from_str_radix(parts[0].trim_start_matches('C'), 8)?;
        let size: u64 = parts[1].parse()?;
        let filename = parts[2].trim();

        let target_path = target_dir.join(filename);

        // Check filter
        if let Some(ref filter) = self.filter {
            if let FilterResult::Deny = filter.check(&target_path, Operation::Upload, &self.user_info.username) {
                self.send_error(writer, "Upload denied by filter").await?;
                // Consume file data
                let mut discard = vec![0u8; size as usize];
                reader.read_exact(&mut discard).await?;
                return Ok(());
            }
        }

        // Send ready
        writer.write_all(&[0]).await?;
        writer.flush().await?;

        // Receive file data
        let mut file = tokio::fs::File::create(&target_path).await?;
        let mut remaining = size;
        let mut buffer = vec![0u8; 64 * 1024];

        while remaining > 0 {
            let to_read = remaining.min(buffer.len() as u64) as usize;
            let n = reader.read(&mut buffer[..to_read]).await?;
            if n == 0 {
                anyhow::bail!("Unexpected EOF");
            }
            file.write_all(&buffer[..n]).await?;
            remaining -= n as u64;
        }

        // Read trailing null byte
        let mut null = [0u8; 1];
        reader.read_exact(&mut null).await?;

        // Set permissions
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&target_path, std::fs::Permissions::from_mode(mode))?;
        }

        // Audit log
        if let Some(ref audit) = self.audit {
            audit.export(AuditEvent {
                event_type: EventType::FileUploaded,
                user: self.user_info.username.clone(),
                path: Some(target_path),
                bytes: Some(size),
                ..Default::default()
            }).await?;
        }

        // Send success
        writer.write_all(&[0]).await?;
        writer.flush().await?;

        Ok(())
    }

    /// Source mode: Send files to client
    async fn run_source<R, W>(&self, reader: &mut R, writer: &mut W) -> Result<()>
    where
        R: AsyncReadExt + Unpin,
        W: AsyncWriteExt + Unpin,
    {
        // Wait for ready signal
        let mut ready = [0u8; 1];
        reader.read_exact(&mut ready).await?;
        
        if ready[0] != 0 {
            anyhow::bail!("Client not ready");
        }

        let source_path = self.resolve_path(&self.path)?;
        
        if source_path.is_dir() {
            if self.recursive {
                self.send_directory(reader, writer, &source_path).await?;
            } else {
                self.send_error(writer, "Is a directory").await?;
            }
        } else {
            self.send_file(reader, writer, &source_path).await?;
        }

        Ok(())
    }

    /// Send a single file
    async fn send_file<R, W>(
        &self,
        reader: &mut R,
        writer: &mut W,
        path: &Path,
    ) -> Result<()>
    where
        R: AsyncReadExt + Unpin,
        W: AsyncWriteExt + Unpin,
    {
        let metadata = tokio::fs::metadata(path).await?;
        let size = metadata.len();
        
        #[cfg(unix)]
        let mode = {
            use std::os::unix::fs::PermissionsExt;
            metadata.permissions().mode() & 0o777
        };
        #[cfg(not(unix))]
        let mode = 0o644;

        let filename = path.file_name()
            .map(|n| n.to_string_lossy())
            .unwrap_or_default();

        // Send header
        let header = format!("C{:04o} {} {}\n", mode, size, filename);
        writer.write_all(header.as_bytes()).await?;
        writer.flush().await?;

        // Wait for acknowledgment
        let mut ack = [0u8; 1];
        reader.read_exact(&mut ack).await?;
        if ack[0] != 0 {
            anyhow::bail!("Client rejected file");
        }

        // Send file data
        let mut file = tokio::fs::File::open(path).await?;
        let mut buffer = vec![0u8; 64 * 1024];

        loop {
            let n = file.read(&mut buffer).await?;
            if n == 0 {
                break;
            }
            writer.write_all(&buffer[..n]).await?;
        }

        // Send trailing null
        writer.write_all(&[0]).await?;
        writer.flush().await?;

        // Wait for final acknowledgment
        reader.read_exact(&mut ack).await?;

        Ok(())
    }

    // Helper methods...
}

2. Integrate with Command Execution

// Update src/server/exec.rs or handler.rs
impl CommandExecutor {
    pub async fn execute(...) -> Result<i32> {
        // Check if this is an SCP command
        if command.starts_with("scp ") {
            if let Ok(scp_handler) = ScpHandler::from_command(command, user_info, &self.config.scp) {
                return self.run_scp(scp_handler, channel, session).await;
            }
        }
        
        // Regular command execution...
    }
}

Files to Create/Modify

File Action
src/server/scp.rs Create - SCP protocol handler
src/server/exec.rs Modify - Integrate SCP detection
src/server/mod.rs Modify - Add scp module

Testing Requirements

  1. Unit test: SCP command parsing
  2. Integration test: File upload with scp
  3. Integration test: File download with scp
  4. Integration test: Recursive directory transfer
  5. Integration test: Permission preservation
# Test file upload
scp -P 2222 local.txt testuser@localhost:/tmp/

# Test file download
scp -P 2222 testuser@localhost:/tmp/remote.txt .

# Test recursive
scp -r -P 2222 ./mydir testuser@localhost:/tmp/

Acceptance Criteria

  • SCP command parsing (-t, -f, -r, -p flags)
  • Sink mode (file upload)
  • Source mode (file download)
  • Recursive directory transfer
  • Permission preservation
  • Filter integration
  • Audit logging
  • 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