diff --git a/Cargo.lock b/Cargo.lock index b347b125..6629141a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,9 +795,9 @@ dependencies = [ [[package]] name = "embassy-futures" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f878075b9794c1e4ac788c95b728f26aa6366d32eeb10c7051389f898f7d067" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" [[package]] name = "embassy-hal-internal" @@ -924,15 +924,15 @@ dependencies = [ [[package]] name = "embassy-sync" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef1a8a1ea892f9b656de0295532ac5d8067e9830d49ec75076291fd6066b136" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" dependencies = [ "cfg-if", "critical-section", "embedded-io-async", + "futures-core", "futures-sink", - "futures-util", "heapless", ] @@ -1792,11 +1792,12 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ - "num_enum_derive 0.7.3", + "num_enum_derive 0.7.5", + "rustversion", ] [[package]] @@ -1812,9 +1813,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", @@ -1924,9 +1925,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pem-rfc7468" @@ -1991,7 +1992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" dependencies = [ "arrayvec", - "num_enum 0.7.3", + "num_enum 0.7.5", "paste", ] @@ -2740,7 +2741,7 @@ name = "sunset-async" version = "0.4.0" dependencies = [ "embassy-futures", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embedded-io-async", "log", "portable-atomic", @@ -2757,7 +2758,7 @@ dependencies = [ "embassy-futures", "embassy-net", "embassy-net-driver", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embedded-io-async", "heapless", @@ -2787,7 +2788,7 @@ dependencies = [ "embassy-net", "embassy-net-wiznet", "embassy-rp", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embassy-usb", "embassy-usb-driver", @@ -2812,6 +2813,32 @@ dependencies = [ "sunset-sshwire-derive", ] +[[package]] +name = "sunset-demo-sftp-std" +version = "0.1.2" +dependencies = [ + "async-io", + "critical-section", + "embassy-executor", + "embassy-futures", + "embassy-net", + "embassy-net-tuntap", + "embassy-sync 0.7.2", + "embassy-time", + "embedded-io-async", + "env_logger", + "fnv", + "heapless", + "libc", + "log", + "rand", + "sha2", + "sunset", + "sunset-async", + "sunset-demo-common", + "sunset-sftp", +] + [[package]] name = "sunset-demo-std" version = "0.1.0" @@ -2822,7 +2849,7 @@ dependencies = [ "embassy-futures", "embassy-net", "embassy-net-tuntap", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embedded-io-async", "env_logger", @@ -2850,6 +2877,21 @@ dependencies = [ "sunset-sshwire-derive", ] +[[package]] +name = "sunset-sftp" +version = "0.1.2" +dependencies = [ + "embassy-futures", + "embassy-sync 0.7.2", + "embedded-io-async", + "log", + "num_enum 0.7.5", + "paste", + "sunset", + "sunset-async", + "sunset-sshwire-derive", +] + [[package]] name = "sunset-sshwire-derive" version = "0.2.1" @@ -2865,7 +2907,7 @@ dependencies = [ "argh", "critical-section", "embassy-futures", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embedded-io-adapters", "embedded-io-async", "futures", diff --git a/Cargo.toml b/Cargo.toml index 61ffdc26..391503f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,11 @@ rust-version = "1.87" [workspace] members = [ "demo/picow", - "demo/std", "fuzz", + "demo/std", + "demo/sftp/std", + "fuzz", "stdasync", + "sftp", # workspace.dependencies paths are automatic ] @@ -39,7 +42,9 @@ ascii = { version = "1.0", default-features = false } arbitrary = { workspace = true, optional = true } getrandom = "0.2" -rand_core = { version = "0.6", default-features = false, features = ["getrandom"]} +rand_core = { version = "0.6", default-features = false, features = [ + "getrandom", +] } ctr = { version = "0.9", features = ["zeroize"] } aes = { version = "0.8", features = ["zeroize"] } @@ -53,14 +58,27 @@ zeroize = { version = "1", default-features = false, features = ["derive"] } cipher = { version = "0.4", features = ["zeroize"] } subtle = { version = "2.4", default-features = false } # ed25519/x25519 -ed25519-dalek = { version = "2.1", default-features = false, features = ["zeroize", "rand_core"] } -x25519-dalek = { version = "2.0", default-features = false, features = ["zeroize"] } -curve25519-dalek = { version = "4.1", default-features = false, features = ["zeroize"] } -ml-kem = { version = "0.2.1", default-features = false, features = ["zeroize"], optional = true } +ed25519-dalek = { version = "2.1", default-features = false, features = [ + "zeroize", + "rand_core", +] } +x25519-dalek = { version = "2.0", default-features = false, features = [ + "zeroize", +] } +curve25519-dalek = { version = "4.1", default-features = false, features = [ + "zeroize", +] } +ml-kem = { version = "0.2.1", default-features = false, features = [ + "zeroize", +], optional = true } # p521 = { version = "0.13.2", default-features = false, features = ["ecdh", "ecdsa"] } -rsa = { version = "0.9", default-features = false, optional = true, features = ["sha2"] } +rsa = { version = "0.9", default-features = false, optional = true, features = [ + "sha2", +] } # TODO: getrandom feature is a workaround for missing ssh-key dependency with rsa. fixed in pending 0.6 -ssh-key = { version = "0.6", default-features = false, optional = true, features = ["getrandom"] } +ssh-key = { version = "0.6", default-features = false, optional = true, features = [ + "getrandom", +] } embedded-io = { version = "0.6", optional = true } diff --git a/demo/sftp/std/Cargo.toml b/demo/sftp/std/Cargo.toml new file mode 100644 index 00000000..a2f08d6f --- /dev/null +++ b/demo/sftp/std/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "sunset-demo-sftp-std" +version = "0.1.2" +edition = "2021" + +[dependencies] +sunset = { workspace = true, features = ["rsa", "std"] } +sunset-async.workspace = true +sunset-demo-common.workspace = true +sunset-sftp = { version = "0.1.0", path = "../../../sftp", features = ["std"] } + +# 131072 was determined empirically +embassy-executor = { version = "0.7", features = [ + "executor-thread", "arch-std", "log", "task-arena-size-131072"] } +embassy-net = { version = "0.7", features = ["tcp", "dhcpv4", "medium-ethernet"] } +embassy-net-tuntap = { version = "0.1" } +embassy-sync = { version = "0.7" } +embassy-futures = { version = "0.1" } +# embassy-time dep required to link a time driver +embassy-time = { version = "0.4", default-features=false, features = ["log", "std"] } + +log = { version = "0.4" } +# default regex feature is huge +env_logger = { version = "0.11", default-features=false, features = ["auto-color", "humantime"] } + +embedded-io-async = "0.6" +heapless = "0.8" + +# for tuntap +libc = "0.2.101" +async-io = "1.6.0" + +# using local fork +# menu = "0.3" + + +critical-section = "1.1" +rand = { version = "0.8", default-features = false, features = ["getrandom"] } +sha2 = { version = "0.10", default-features = false } +fnv = "1.0.7" diff --git a/demo/sftp/std/README.md b/demo/sftp/std/README.md new file mode 100644 index 00000000..98394a10 --- /dev/null +++ b/demo/sftp/std/README.md @@ -0,0 +1,64 @@ +# sunset-demo-sftp-std + +`demo/sftp/std` contains a host-side (`std`) demo that runs an SSH server with SFTP support using the `sunset` and `sunset-sftp` crates. It runs on linux distributions. + +It is intended as a **reference implementation** for building your own SFTP server with `sunset-sftp`. It is not a complete implementation and you should make your own choices for your sftp server. + +In particular, this demo shows how to: + +- implement an `SftpServer` for request handling +- add a `FileHandleManager` to track/open/close active handles +- define an `OpaqueFileHandle` format to safely encode/decode handle IDs across requests + +Use `src/demosftpserver.rs`, `src/demofilehandlemanager.rs`, and `src/demoopaquefilehandle.rs` together with `main.rs` and common demo files as a reference for custom server development. + +## What this folder contains + +- `src/main.rs` + Demo entry point. Sets up logging, runtime/executor, network stack, and starts the SSH/SFTP demo server. +- `src/demosftpserver.rs` + Demo SFTP server wiring and request handling glue. +- `src/demofilehandlemanager.rs` + Tracks and manages open file handles used by the SFTP session. +- `src/demoopaquefilehandle.rs` + Defines/encodes opaque file handle values used by the demo protocol layer. +- `tap.sh` + Helper script to create/configure a TAP interface for local testing. +- `debug_sftp_client.sh` + Convenience script for running an SFTP client in a debug-friendly way. +- `testing/` + Test and log scripts (read/write/stat/readdir scenarios, log helpers, and parsing utilities). + +## Setup + +This demo uses a tap interface to run the server and accept connections. The tap.sh sets this up in a linux environment. I have not find a way to run this on MacOS. On windows I recommend using WSL2. + +Run: + +```bash +sudo ./tap.sh +``` + +## Build / run + +From base project folder `sunset`: + +```bash +cargo run -p sunset-demo-sftp-std +``` + +Then connect with an SFTP client using the configured demo host/user settings. The first info log will display the server ipv4 address. + +## Testing + +`testing/` contains runnable scripts and utilities to validate SFTP behavior end-to-end. It includes scenarios for: + +- file reads/writes +- `stat`/metadata checks +- directory listing (`readdir`) +- log capture and parsing helpers (Requires a tshark installation with the current user in wireshark group) + +These scripts are useful both for regression checks and as examples of expected server behavior during development. + +these scripts have been used through the development of `sunset-sftp` and might not respond to a general use but some particular troubleshooting. I hope that they are useful as a reference for you exploration. + diff --git a/demo/sftp/std/debug_sftp_client.sh b/demo/sftp/std/debug_sftp_client.sh new file mode 100755 index 00000000..f67b1df6 --- /dev/null +++ b/demo/sftp/std/debug_sftp_client.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# This sftp options are meant to help debugging and do not store any host key or known hosts information. +# That is not a good practice in real life, as it can lead to security issues, but it is useful for debugging purposes. + +sftp -vvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR any@192.168.69.2 \ No newline at end of file diff --git a/demo/sftp/std/rust-toolchain.toml b/demo/sftp/std/rust-toolchain.toml new file mode 100644 index 00000000..9993e936 --- /dev/null +++ b/demo/sftp/std/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = [ "rustfmt" ] diff --git a/demo/sftp/std/src/demofilehandlemanager.rs b/demo/sftp/std/src/demofilehandlemanager.rs new file mode 100644 index 00000000..b1c649ba --- /dev/null +++ b/demo/sftp/std/src/demofilehandlemanager.rs @@ -0,0 +1,66 @@ +use sunset_sftp::handles::{ + InitWithSeed, OpaqueFileHandle, OpaqueFileHandleManager, PathFinder, +}; +use sunset_sftp::protocol::StatusCode; + +use std::collections::HashMap; // Not enforced. Only for std. For no_std environments other solutions can be used to store Key, Value + +pub struct DemoFileHandleManager +where + K: OpaqueFileHandle + InitWithSeed, + V: PathFinder, +{ + handle_map: HashMap, +} + +impl DemoFileHandleManager +where + K: OpaqueFileHandle + InitWithSeed, + V: PathFinder, +{ + pub fn new() -> Self { + Self { handle_map: HashMap::new() } + } +} + +impl OpaqueFileHandleManager for DemoFileHandleManager +where + K: OpaqueFileHandle + InitWithSeed, + V: PathFinder, +{ + type Err = StatusCode; + + fn insert(&mut self, private_handle: V, salt: &str) -> Result { + if self + .handle_map + .iter() + .any(|(_, private_handle)| private_handle.matches(&private_handle)) + { + return Err(StatusCode::SSH_FX_PERMISSION_DENIED); + } + + let handle = K::init_with_seed( + format!("{:}-{:}", &private_handle.get_path_ref(), salt).as_str(), + ) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + self.handle_map.insert(handle.clone(), private_handle); + Ok(handle) + } + + fn remove(&mut self, opaque_handle: &K) -> Option { + self.handle_map.remove(opaque_handle) + } + + fn opaque_handle_exist(&self, opaque_handle: &K) -> bool { + self.handle_map.contains_key(opaque_handle) + } + + fn get_private_as_ref(&self, opaque_handle: &K) -> Option<&V> { + self.handle_map.get(opaque_handle) + } + + fn get_private_as_mut_ref(&mut self, opaque_handle: &K) -> Option<&mut V> { + self.handle_map.get_mut(opaque_handle) + } +} diff --git a/demo/sftp/std/src/demoopaquefilehandle.rs b/demo/sftp/std/src/demoopaquefilehandle.rs new file mode 100644 index 00000000..e5bad21b --- /dev/null +++ b/demo/sftp/std/src/demoopaquefilehandle.rs @@ -0,0 +1,45 @@ +use sunset_sftp::handles::{InitWithSeed, OpaqueFileHandle}; +use sunset_sftp::protocol::FileHandle; + +use sunset::sshwire::{BinString, WireError}; + +use core::hash::Hasher; + +use fnv::FnvHasher; + +const HASH_LEN: usize = 4; +#[derive(Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) struct DemoOpaqueFileHandle { + tiny_hash: [u8; HASH_LEN], +} + +impl OpaqueFileHandle for DemoOpaqueFileHandle { + fn try_from(file_handle: &FileHandle<'_>) -> sunset::sshwire::WireResult { + if !file_handle.0 .0.len().eq(&core::mem::size_of::()) + { + return Err(WireError::BadString); + } + + let mut tiny_hash = [0u8; HASH_LEN]; + tiny_hash.copy_from_slice(file_handle.0 .0); + Ok(DemoOpaqueFileHandle { tiny_hash }) + } + + fn into_file_handle(&self) -> FileHandle<'_> { + FileHandle(BinString(&self.tiny_hash)) + } +} + +/// Implemented to allow the use of `DemoOpaqueFileHandle` as a key in the `OpaqueHandleManager` +impl InitWithSeed for DemoOpaqueFileHandle { + type Err = WireError; + + fn init_with_seed(seed: &str) -> Result { + let mut hasher = FnvHasher::default(); + hasher.write(seed.as_bytes()); + + Ok(DemoOpaqueFileHandle { + tiny_hash: (hasher.finish() as u32).to_be_bytes(), + }) + } +} diff --git a/demo/sftp/std/src/demosftpserver.rs b/demo/sftp/std/src/demosftpserver.rs new file mode 100644 index 00000000..60cad253 --- /dev/null +++ b/demo/sftp/std/src/demosftpserver.rs @@ -0,0 +1,449 @@ +use crate::demofilehandlemanager::DemoFileHandleManager; + +use sunset_sftp::error::SftpResult; +use sunset_sftp::handles::{ + InitWithSeed, OpaqueFileHandle, OpaqueFileHandleManager, PathFinder, +}; +use sunset_sftp::protocol::{Attrs, Filename, NameEntry, PFlags, StatusCode}; +use sunset_sftp::server::helpers::DirEntriesCollection; +use sunset_sftp::server::{ + DirReply, ReadReply, ReadStatus, SftpOpResult, SftpServer, +}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::{fs::File, os::unix::fs::FileExt, path::Path}; + +// Used during read operations +const ARBITRARY_READ_BUFFER_LENGTH: usize = 1024; + +#[derive(Debug)] +pub(crate) enum PrivatePathHandle { + File(PrivateFileHandle), + Directory(PrivateDirHandle), +} + +#[derive(Debug)] +pub(crate) struct PrivateFileHandle { + path: String, + permissions: Option, + file: File, +} + +#[derive(Debug)] +pub(crate) struct PrivateDirHandle { + path: String, + read_status: ReadStatus, +} + +/// It is a better practice generating it on creation. Used to generate the opaque handles instead of using a constant +static OPAQUE_SALT: &'static str = "12d%32"; + +impl PathFinder for PrivatePathHandle { + fn matches(&self, path: &Self) -> bool { + match self { + PrivatePathHandle::File(self_private_path_handler) => { + if let PrivatePathHandle::File(private_file_handle) = path { + return self_private_path_handler.matches(private_file_handle); + } else { + false + } + } + PrivatePathHandle::Directory(self_private_dir_handle) => { + if let PrivatePathHandle::Directory(private_dir_handle) = path { + self_private_dir_handle.matches(private_dir_handle) + } else { + false + } + } + } + } + + fn get_path_ref(&self) -> &str { + match self { + PrivatePathHandle::File(private_file_handler) => { + private_file_handler.get_path_ref() + } + PrivatePathHandle::Directory(private_dir_handle) => { + private_dir_handle.get_path_ref() + } + } + } +} + +impl PathFinder for PrivateFileHandle { + fn matches(&self, path: &PrivateFileHandle) -> bool { + self.path.as_str().eq_ignore_ascii_case(path.get_path_ref()) + } + + fn get_path_ref(&self) -> &str { + self.path.as_str() + } +} + +impl PathFinder for PrivateDirHandle { + fn matches(&self, path: &PrivateDirHandle) -> bool { + self.path.as_str().eq_ignore_ascii_case(path.get_path_ref()) + } + + fn get_path_ref(&self) -> &str { + self.path.as_str() + } +} + +/// A basic demo server. Used as a demo and to test SFTP functionality +pub struct DemoSftpServer { + base_path: String, + handles_manager: DemoFileHandleManager, +} + +impl DemoSftpServer { + pub fn new(base_path: String) -> Self { + if !Path::new(&base_path).exists() { + debug!("Base path {:?} does not exist. Creating it", base_path); + if let Err(err) = fs::create_dir_all(&base_path) { + error!("Could not create the base path {:?}: {:?}", base_path, err); + panic!(); + } + } else { + debug!("Base path {:?} already exists", base_path); + } + DemoSftpServer { base_path, handles_manager: DemoFileHandleManager::new() } + } +} + +impl SftpServer<'_, OFH> + for DemoSftpServer +{ + async fn open(&mut self, filename: &str, mode: &PFlags) -> SftpOpResult { + debug!("Open file: filename = {:?}, mode = {:?}", filename, mode); + + let can_write = u32::from(mode) & u32::from(&PFlags::SSH_FXF_WRITE) > 0; + let can_read = u32::from(mode) & u32::from(&PFlags::SSH_FXF_READ) > 0; + + info!( + "File open for read/write access: can_read={:?}, can_write={:?}", + can_read, can_write + ); + + let file = File::options() + .read(can_read) + .write(can_write) + .create(can_write) + .open(filename) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + let permissions = file + .metadata() + .map_err(|_| StatusCode::SSH_FX_FAILURE)? + .permissions() + .mode() + & 0o777; + + let fh = self.handles_manager.insert( + PrivatePathHandle::File(PrivateFileHandle { + path: filename.into(), + permissions: Some(permissions), + file, + }), + OPAQUE_SALT, + ); + + debug!( + "Filename \"{:?}\" will have the obscured file handle: {:?}", + filename, fh + ); + + fh + } + + async fn opendir(&mut self, dir: &str) -> SftpOpResult { + info!("Open Directory = {:?}", dir); + + let dir_handle = self.handles_manager.insert( + PrivatePathHandle::Directory(PrivateDirHandle { + path: dir.into(), + read_status: ReadStatus::default(), + }), + OPAQUE_SALT, + ); + + debug!( + "Directory \"{:?}\" will have the obscured file handle: {:?}", + dir, dir_handle + ); + + dir_handle + } + + async fn realpath(&mut self, dir: &str) -> SftpOpResult> { + info!("finding path for: {:?}", dir); + let name_entry = NameEntry { + filename: Filename::from(self.base_path.as_str()), + _longname: Filename::from(""), + attrs: Attrs { + size: None, + uid: None, + gid: None, + permissions: None, + atime: None, + mtime: None, + ext_count: None, + }, + }; + debug!("Will return: {:?}", name_entry); + Ok(name_entry) + } + + async fn close(&mut self, opaque_file_handle: &OFH) -> SftpOpResult<()> { + if let Some(handle) = self.handles_manager.remove(opaque_file_handle) { + match handle { + PrivatePathHandle::File(private_file_handle) => { + info!( + "SftpServer Close operation on file {:?} was successful", + private_file_handle.path + ); + drop(private_file_handle.file); // Not really required but illustrative + Ok(()) + } + PrivatePathHandle::Directory(private_dir_handle) => { + info!( + "SftpServer Close operation on dir {:?} was successful", + private_dir_handle.path + ); + + Ok(()) + } + } + } else { + error!( + "SftpServer Close operation on handle {:?} failed", + opaque_file_handle + ); + Err(StatusCode::SSH_FX_FAILURE) + } + } + + async fn read( + &mut self, + opaque_file_handle: &OFH, + offset: u64, + len: u32, + reply: &mut ReadReply<'_, N>, + ) -> SftpResult<()> { + if let PrivatePathHandle::File(private_file_handle) = self + .handles_manager + .get_private_as_mut_ref(opaque_file_handle) + .ok_or(StatusCode::SSH_FX_FAILURE)? + { + log::debug!( + "SftpServer Read operation: handle = {:?}, filepath = {:?}, offset = {:?}, len = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + len + ); + let permissions_poxit = private_file_handle.permissions.unwrap_or(0o000); + if (permissions_poxit & 0o444) == 0 { + error!( + "No read permissions for file {:?}", + private_file_handle.path + ); + return Err(StatusCode::SSH_FX_PERMISSION_DENIED.into()); + }; + + let file_len = private_file_handle + .file + .metadata() + .map_err(|err| { + error!("Could not read the file length: {:?}", err); + StatusCode::SSH_FX_FAILURE + })? + .len(); + + if offset >= file_len { + info!( + "offset is larger than file length, sending EOF for {:?}", + private_file_handle.path + ); + reply.send_eof().await.map_err(|err| { + error!("Could not sent EOF: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + return Ok(()); + } + + let read_len = if file_len >= len as u64 + offset { + len + } else { + debug!("Read operation: length + offset > file length. Clipping ( {:?} + {:?} > {:?})", + len, offset, file_len); + (file_len - offset).try_into().unwrap_or(u32::MAX) + }; + + reply.send_header(read_len).await?; + + let mut read_buff = [0u8; ARBITRARY_READ_BUFFER_LENGTH]; + + let mut running_offset = offset; + let mut remaining = read_len as usize; + + debug!("Starting reading loop: remaining = {}", remaining); + while remaining > 0 { + let next_read_len: usize = remaining.min(read_buff.len()); + trace!("next_read_len = {}", next_read_len); + let br = private_file_handle + .file + .read_at(&mut read_buff[..next_read_len], running_offset) + .map_err(|err| { + error!("read error: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + trace!("{} bytes readed", br); + reply.send_data(&read_buff[..br.min(remaining)]).await?; + trace!("Read sent {} bytes", br.min(remaining)); + trace!("remaining {} bytes. {} byte read", remaining, br); + + remaining = + remaining.checked_sub(br).ok_or(StatusCode::SSH_FX_FAILURE)?; + trace!( + "after subtracting {} bytes, there are {} bytes remaining", + br, + remaining + ); + running_offset = running_offset + .checked_add(br as u64) + .ok_or(StatusCode::SSH_FX_FAILURE)?; + } + debug!("Finished sending data"); + return Ok(()); + } + Err(StatusCode::SSH_FX_PERMISSION_DENIED.into()) + } + + async fn write( + &mut self, + opaque_file_handle: &OFH, + offset: u64, + buf: &[u8], + ) -> SftpOpResult<()> { + if let PrivatePathHandle::File(private_file_handle) = self + .handles_manager + .get_private_as_ref(opaque_file_handle) + .ok_or(StatusCode::SSH_FX_FAILURE)? + { + let permissions_poxit = (private_file_handle + .permissions + .ok_or(StatusCode::SSH_FX_PERMISSION_DENIED))?; + + if (permissions_poxit & 0o222) == 0 { + return Err(StatusCode::SSH_FX_PERMISSION_DENIED); + }; + + log::trace!( + "SftpServer Write operation: handle = {:?}, filepath = {:?}, offset = {:?}, buf = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + String::from_utf8(buf.to_vec()) + ); + let bytes_written = private_file_handle + .file + .write_at(buf, offset) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + log::debug!( + "SftpServer Write operation: handle = {:?}, filepath = {:?}, offset = {:?}, buffer length = {:?}, bytes written = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + buf.len(), + bytes_written + ); + + Ok(()) + } else { + Err(StatusCode::SSH_FX_PERMISSION_DENIED) + } + } + + async fn readdir( + &mut self, + opaque_dir_handle: &OFH, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + info!("read dir for {:?}", opaque_dir_handle); + + if let PrivatePathHandle::Directory(dir) = self + .handles_manager + .get_private_as_mut_ref(opaque_dir_handle) + .ok_or(StatusCode::SSH_FX_NO_SUCH_FILE)? + { + if dir.read_status == ReadStatus::EndOfFile { + reply.send_eof().await.map_err(|error| { + error!("{:?}", error); + StatusCode::SSH_FX_FAILURE + })?; + return Ok(()); + } + + let path_str = dir.path.clone(); + debug!("opaque handle found in handles manager: {:?}", path_str); + let dir_path = Path::new(&path_str); + debug!("path: {:?}", dir_path); + + if dir_path.is_dir() { + info!("SftpServer ReadDir operation path = {:?}", dir_path); + + let dir_iterator = fs::read_dir(dir_path).map_err(|err| { + error!("could not get the directory {:?}: {:?}", path_str, err); + StatusCode::SSH_FX_PERMISSION_DENIED + })?; + + let name_entry_collection = DirEntriesCollection::new(dir_iterator)?; + + let response_read_status = + name_entry_collection.send_response(reply).await?; + + dir.read_status = response_read_status; + return Ok(()); + } else { + error!("the path is not a directory = {:?}", dir_path); + return Err(StatusCode::SSH_FX_NO_SUCH_FILE); + } + } else { + error!("Could not find the directory for {:?}", opaque_dir_handle); + return Err(StatusCode::SSH_FX_NO_SUCH_FILE); + } + } + + async fn attrs( + &mut self, + follow_links: bool, + file_path: &str, + ) -> SftpOpResult { + log::debug!("SftpServer ListStats: file_path = {:?}", file_path); + let file_path = Path::new(file_path); + + let metadata = if follow_links { + file_path.metadata() // follows symlinks + } else { + file_path.symlink_metadata() // doesn't follow symlinks + } + .map_err(|err| { + error!("Problem listing stats: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + + if file_path.is_file() { + return Ok(sunset_sftp::server::helpers::get_file_attrs(metadata)); + } else if file_path.is_symlink() { + return Ok(sunset_sftp::server::helpers::get_file_attrs(metadata)); + } else { + return Err(StatusCode::SSH_FX_NO_SUCH_FILE); + } + } +} diff --git a/demo/sftp/std/src/main.rs b/demo/sftp/std/src/main.rs new file mode 100644 index 00000000..efc7835b --- /dev/null +++ b/demo/sftp/std/src/main.rs @@ -0,0 +1,231 @@ +use sunset::*; +use sunset_async::{ProgressHolder, SSHServer, SunsetMutex, SunsetRawMutex}; +use sunset_sftp::{server::MAX_REQUEST_LEN, SftpHandler}; + +pub(crate) use sunset_demo_common as demo_common; + +use demo_common::{DemoCommon, DemoServer, SSHConfig}; + +use crate::{ + demoopaquefilehandle::DemoOpaqueFileHandle, demosftpserver::DemoSftpServer, +}; + +use embassy_executor::Spawner; +use embassy_net::{Stack, StackResources, StaticConfigV4}; + +use rand::rngs::OsRng; +use rand::RngCore; + +use embassy_futures::select::select; +use embassy_net_tuntap::TunTapDevice; +use embassy_sync::channel::Channel; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +mod demofilehandlemanager; +mod demoopaquefilehandle; +mod demosftpserver; + +const NUM_LISTENERS: usize = 4; +// +1 for dhcp +const NUM_SOCKETS: usize = NUM_LISTENERS + 1; + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, TunTapDevice>) -> ! { + runner.run().await +} + +#[embassy_executor::task] +async fn main_task(spawner: Spawner) { + let opt_tap0 = "tap0"; + let ip4 = "192.168.69.2"; + let cir = 24; + + let config = Box::leak(Box::new({ + let mut config = SSHConfig::new().unwrap(); + config.set_admin_pw(Some("pw")).unwrap(); + config.console_noauth = true; + config.ip4_static = if let Ok(ip) = ip4.parse() { + Some(StaticConfigV4 { + address: embassy_net::Ipv4Cidr::new(ip, cir), + gateway: None, + dns_servers: { heapless::Vec::new() }, + }) + } else { + None + }; + SunsetMutex::new(config) + })); + + let net_cf = if let Some(ref s) = config.lock().await.ip4_static { + embassy_net::Config::ipv4_static(s.clone()) + } else { + embassy_net::Config::dhcpv4(Default::default()) + }; + info!("Net config: {net_cf:?}"); + + // Init network device + let net_device = TunTapDevice::new(opt_tap0).unwrap(); + + let seed = OsRng.next_u64(); + + // Init network stack + let res = Box::leak(Box::new(StackResources::::new())); + let (stack, runner) = embassy_net::new(net_device, net_cf, res, seed); + + // Launch network task + spawner.spawn(net_task(runner)).unwrap(); + + for _ in 0..NUM_LISTENERS { + spawner.spawn(listen(stack, config)).unwrap(); + } +} + +#[derive(Default)] +struct StdDemo; + +impl DemoServer for StdDemo { + async fn run(&self, serv: &SSHServer<'_>, mut common: DemoCommon) -> Result<()> { + let chan_pipe = Channel::::new(); + + let ssh_loop_inner = async { + loop { + let mut ph = ProgressHolder::new(); + let ev = match serv.progress(&mut ph).await { + Ok(event) => event, + Err(e) => { + match e { + Error::NoRoom {} => { + warn!("NoRoom triggered. Trying again"); + continue; + } + _ => { + error!("server progress failed: {:?}", e); // NoRoom: 2048 Bytes Output buffer + return Err(e); + } + } + } + }; + + trace!("ev {ev:?}"); + match ev { + ServEvent::SessionShell(a) => { + a.fail()?; // Not allowed in this example, kept here for compatibility + } + ServEvent::SessionExec(a) => { + a.fail()?; // Not allowed in this example, kept here for compatibility + } + ServEvent::SessionSubsystem(a) => { + match a.command()?.to_lowercase().as_str() { + "sftp" => { + info!("Starting '{}' subsystem", a.command()?); + + if let Some(ch) = common.sess.take() { + debug_assert!(ch.num() == a.channel()); + a.succeed()?; + let _ = chan_pipe.try_send(ch); + } else { + a.fail()?; + } + } + _ => { + warn!( + "request for subsystem '{}' not implemented: fail", + a.command()? + ); + a.fail()?; + } + } + } + other => common.handle_event(other)?, + }; + } + #[allow(unreachable_code)] + Ok::<_, Error>(()) + }; + + let ssh_loop = async { + info!("prog_loop started"); + if let Err(e) = ssh_loop_inner.await { + warn!("Prog Loop Exited: {e:?}"); + return Err(e); + } + Ok(()) + }; + + #[allow(unreachable_code)] + let sftp_loop = async { + loop { + let ch = chan_pipe.receive().await; + + info!("SFTP loop has received a channel handle {:?}", ch.num()); + + // TODO Do some research to find reasonable default buffer lengths + let mut buffer_in = [0u8; 512]; + let mut request_buffer = [0u8; MAX_REQUEST_LEN]; + + match { + let stdio = serv.stdio(ch).await?; + let mut file_server = + DemoSftpServer::::new( + "./demo/sftp/std/testing/out/".to_string(), + ); + + SftpHandler::< + DemoOpaqueFileHandle, + DemoSftpServer, + 512, + >::new(&mut file_server, &mut request_buffer) + .process_loop(stdio, &mut buffer_in) + .await?; + + Ok::<_, Error>(()) + } { + Ok(_) => { + warn!("sftp server loop finished gracefully"); + return Ok(()); + } + Err(e) => { + error!("sftp server loop finished with an error: {}", e); + return Err(e); + } + }; + } + Ok::<_, Error>(()) + }; + + let selected = select(ssh_loop, sftp_loop).await; + match selected { + embassy_futures::select::Either::First(res) => { + warn!("prog_loop finished: {:?}", res); + res + } + embassy_futures::select::Either::Second(res) => { + warn!("sftp_loop finished: {:?}", res); + res + } + } + } +} + +// TODO pool_size should be NUM_LISTENERS but needs a literal +#[embassy_executor::task(pool_size = 4)] +async fn listen( + stack: Stack<'static>, + config: &'static SunsetMutex, +) -> ! { + let demo = StdDemo::default(); + demo_common::listen(stack, config, &demo).await +} + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .format_timestamp_nanos() + .target(env_logger::Target::Stdout) + .init(); + + spawner.spawn(main_task(spawner)).unwrap(); +} diff --git a/demo/sftp/std/tap.sh b/demo/sftp/std/tap.sh new file mode 100755 index 00000000..8732a0cd --- /dev/null +++ b/demo/sftp/std/tap.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# This script generates the tap device that the demo will bind the network stack +# usage `sudo ./tap.sh` + +ip tuntap add name tap0 mode tap user $SUDO_USER group $SUDO_USER +ip addr add 192.168.69.100/24 dev tap0 +ip link set tap0 up diff --git a/demo/sftp/std/testing/extract_txrx.sh b/demo/sftp/std/testing/extract_txrx.sh new file mode 100755 index 00000000..f2c7ab82 --- /dev/null +++ b/demo/sftp/std/testing/extract_txrx.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Find all lines containing SFTP... OR Output Consumer... OR Output Producer... +# and reformat them into a simpler form for further processing. + + +cat < ${1}.txrx +Extracting communications from sunset-demo-sftp-std log file: $1 +Extract of RX (c: Client), TX (s: server), And internal TX (p: pipe producer) +------------------------------------------------ +EOF + +cat $1 | \ +grep -E 'SFTP <---- received: \[|Output Consumer: Bytes written \[|Output Producer: Sending buffer \[' | \ +sed 's/.*received: /c / ; s/.*written /s / ; s/.*Output Producer: Sending buffer /p /' >> ${1}.txrx + + +# Extract received lines. Remove brackets, spaces, +# and split by comma into new lines. Finally remove empty lines. + +# RX +cat $1 | \ +grep -E 'SFTP <---- received: \[' | \ +sed 's/.*received: //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.rx + +# Producer +cat $1 | \ +grep -E 'Output Producer: Sending buffer \[' | \ +sed 's/.*buffer //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.txp + +# TX +cat $1 | \ +grep -E 'Output Consumer: Bytes written \[' | \ +sed 's/.*written //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.tx \ No newline at end of file diff --git a/demo/sftp/std/testing/log_demo_sftp_with_test.sh b/demo/sftp/std/testing/log_demo_sftp_with_test.sh new file mode 100755 index 00000000..8e44f352 --- /dev/null +++ b/demo/sftp/std/testing/log_demo_sftp_with_test.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Used to run sftp demo while logging all the interactions with strace and tshark. +# The passed argument is the test file to run, for example: ./test_get_long.sh or ./test_get_short.sh +# This script will be run once the sftp demo is running. + +TIME_STAMP=$(date +%Y%m%d_%H%M%S) +TEST_FILE=$1 +# Used for log files naming +BASE_NAME=$(basename "$TEST_FILE" | cut -d. -f1) +START_PWD=$PWD +PROYECT_ROOT=$(dirname "$PWD")/../../.. + +# Check if file exist and can be executed + +if [ ! -f "${TEST_FILE}" ]; then + echo "File ${TEST_FILE} not found" + exit 1 +fi +if [ ! -x "${TEST_FILE}" ]; then + echo "File ${TEST_FILE} is not executable" + exit 2 +fi + +echo "debuging file: $TEST_FILE with logging and pcap" + +cargo build -p sunset-demo-sftp-std +if [ $? -ne 0 ]; then + echo "Failed to build sunset-demo-sftp-std. Aborting" + return 1 +fi + +sleep 3; +clear; + +# Create logs directory if it doesn't exist +LOG_DIR="$PWD/logs" +mkdir -p "$LOG_DIR" + + +# Starts an Tshark session to capture packets in tap0 +WIRESHARK_LOG=${LOG_DIR}/${TIME_STAMP}_${BASE_NAME}.pcap +tshark -i tap0 -w ${WIRESHARK_LOG} & +TSHARK_PID=$! + +# waits while tshark started writting to the file +echo "Waiting for tshark to start..." + +while [ ! -s "${WIRESHARK_LOG}" ]; do + sleep 1 +done +echo "Tshark has started." + +# ################################################################ +# Start the sunset-demo-sftp-std with strace +# ################################################################ +echo "Starting sunset-demo-sftp-std" +echo "Changing directory to Project root: ${PROYECT_ROOT}" +cd ${PROYECT_ROOT} +echo "Project root directory is: ${PWD}" +RUST_LOG_FILE="${LOG_DIR}/${TIME_STAMP}_${BASE_NAME}.log" +STRACE_LOG=${LOG_DIR}/${TIME_STAMP}_${BASE_NAME}_strace.log +STRACE_OPTIONS="-fintttCDTYyy -v" +STRACE_CMD="strace ${STRACE_OPTIONS} -o ${STRACE_LOG} -P /dev/net/tun ./target/debug/sunset-demo-sftp-std" + +echo "Running strace for sunset-demo-sftp-std:" +echo "TZ=UTC ${STRACE_CMD}" +TZ=UTC ${STRACE_CMD} 2>&1 > $RUST_LOG_FILE & +STRACE_PID=$! + +echo "Sleeping for 2 seconds to let the server start..." +sleep 2 + +echo "Changing back to the starting directory: $START_PWD" +cd $START_PWD + +echo "Cleaning up previous run files" +rm -f -r ./*_random ./out/*_random + +echo "Running ${TEST_FILE}. Logging all data to ${LOG_DIR} with prefix ${TIME_STAMP}." +${TEST_FILE} | awk '{ cmd = "date -u +\"[%Y-%m-%dT%H:%M:%S.%NZ]\""; cmd | getline timestamp; print timestamp, $0; close(cmd) }' > $LOG_DIR/${TIME_STAMP}_${BASE_NAME}_client.log 2>&1 & +TEST_FILE_PID=$! + +kill_test(){ + echo "traped signal, killing test file process ${TEST_FILE_PID}" + kill -SIGTERM $TEST_FILE_PID +} +cleanup() { + echo "Cleaning up..." + if kill -0 $TSHARK_PID 2>/dev/null; then + echo "Killing tshark process ${TSHARK_PID}" + kill -SIGTERM $TSHARK_PID + fi + if kill -0 $STRACE_PID 2>/dev/null; then + echo "Killing strace process ${STRACE_PID}" + kill -SIGTERM $STRACE_PID + fi + echo "Cleanup done." +} + +trap kill_test SIGINT SIGTERM + +echo "If stuck use Ctrl+C to stop the script and cleanup." +wait "$TEST_FILE_PID" +echo "Finished executing ${TEST_FILE}" + +echo "extracting TX/RX data from log file..." +./extract_txrx.sh $RUST_LOG_FILE + +cleanup diff --git a/demo/sftp/std/testing/log_get_file_long.sh b/demo/sftp/std/testing/log_get_file_long.sh new file mode 100755 index 00000000..a955cb5b --- /dev/null +++ b/demo/sftp/std/testing/log_get_file_long.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./log_demo_sftp_with_test.sh ./test_get_file_long.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/log_get_file_short.sh b/demo/sftp/std/testing/log_get_file_short.sh new file mode 100755 index 00000000..627280c4 --- /dev/null +++ b/demo/sftp/std/testing/log_get_file_short.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./log_demo_sftp_with_test.sh ./test_get_file_short.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/merge_logs.sh b/demo/sftp/std/testing/merge_logs.sh new file mode 100755 index 00000000..07b03b5b --- /dev/null +++ b/demo/sftp/std/testing/merge_logs.sh @@ -0,0 +1,10 @@ +# merge-logs.sh +# Useful to get events from both client and server logs in chronological order. +# +# usage: ./merge-logs.sh client.log server.log > merged.log +CLIENT_LOG=$1 +SERVER_LOG=$2 +{ + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)\]/,m){print m[1] "\tC:\t" $0}' "$CLIENT_LOG" + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)/,m){print m[1] "\tS:\t" $0}' "$SERVER_LOG" +} | sort -t $'\t' -k1,1 | cut -f2- \ No newline at end of file diff --git a/demo/sftp/std/testing/test_get_file_long.sh b/demo/sftp/std/testing/test_get_file_long.sh new file mode 100755 index 00000000..aeb51fb5 --- /dev/null +++ b/demo/sftp/std/testing/test_get_file_long.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests the GET command with a single file of 100MB +# It tests if the downloaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Single long GETs..." + +echo "Cleaning up previous run files" +rm -f -r $BASE_DIR/*_random $REMOTE_DIR/*_random + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + + + +# Generate random data files +echo "Generating random data files..." +# Define test files +FILES=("100MB_random") + +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=100 of=$REMOTE_DIR/100MB_random 2>/dev/null + + +echo "Downloading files..." +sftp -vvvvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} 2>&1 << EOF +$(printf 'get ./%s\n' "${FILES[@]}") +bye +EOF + +echo "DOWNLOAD Test Results:" +echo "=============" +# Test each file +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" + else + echo "Download FAIL: Keeping downloaded and remote files for inspection + ${BASE_DIR}/${file} and ${REMOTE_DIR}/${file}" + fi +done diff --git a/demo/sftp/std/testing/test_get_file_short.sh b/demo/sftp/std/testing/test_get_file_short.sh new file mode 100755 index 00000000..e1cc6b64 --- /dev/null +++ b/demo/sftp/std/testing/test_get_file_short.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests the GET command with a single file of 1MB +# It tests if the downloaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Single short GETs..." + +echo "Cleaning up previous run files" +rm -f -r $BASE_DIR/*_random $REMOTE_DIR/*_random + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + + + +# Generate random data files +echo "Generating random data files..." +# Define test files +FILES=("1MB_random") + +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=1 of=$REMOTE_DIR/1MB_random 2>/dev/null + + +echo "Downloading files..." +sftp -vvvvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} 2>&1 << EOF +$(printf 'get ./%s\n' "${FILES[@]}") +bye +EOF + +echo "DOWNLOAD Test Results:" +echo "=============" +# Test each file +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" + else + echo "Download FAIL: Keeping downloaded and remote files for inspection + ${BASE_DIR}/${file} and ${REMOTE_DIR}/${file}" + fi +done diff --git a/demo/sftp/std/testing/test_ls_dir.sh b/demo/sftp/std/testing/test_ls_dir.sh new file mode 100755 index 00000000..a022a2e7 --- /dev/null +++ b/demo/sftp/std/testing/test_ls_dir.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Writes some files in the remote server folder and list them with the LS command +# Run it from the project root directory or testing folder +# This script requires expect tool + +if ! command -v expect >/dev/null 2>&1; then + echo "Error: 'expect' is not installed or not in PATH." + echo "Please install it and run this test again." + exit 1 +fi + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("A_random" "B_random" "D_random" "E_random" "F_random" "G_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$REMOTE_DIR/512B_random 2>/dev/null + +# Generating copies of the test file +echo "Creating copies for each test file..." +for file in "${FILES[@]}"; do + cp $REMOTE_DIR/512B_random "$REMOTE_DIR/${file}" +done + +rm $REMOTE_DIR/512B_random + +echo "Files created in remote folder ($REMOTE_DIR):" +echo "=============" +ls -l $REMOTE_DIR +echo "" + +# Using expect to automate the sftp session and list the files in the remote folder +# Comparing them to the expected files list + + +echo "Checking that the filenames are present" +echo "==============" + + +FILES_STR="${FILES[*]}" +export FILES_STR REMOTE_HOST REMOTE_USER +expect << 'EOF' +set timeout 20 + +spawn sftp -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $env(REMOTE_USER)@$env(REMOTE_HOST) + +# Wait for sftp> prompt +expect { + -re {(?m)^sftp> ?$} {} + -re {(?i)password:} { + puts "ERROR: password prompt received" + exit 1 + } + -re {.+\n} { exp_continue } + timeout { + puts "ERROR: did not receive sftp prompt" + exit 1 + } + eof { + puts "ERROR: sftp terminated before showing prompt" + exit 1 + } +} + +send -- "ls -1\r" +expect { + -re {(?ms)(.*)\r?\nsftp> ?$} { + set ls_output $expect_out(1,string) + } + timeout { + puts "ERROR: did not receive prompt after ls" + exit 1 + } + eof { + puts "ERROR: sftp terminated after ls" + exit 1 + } +} +# Normalize CRLF -> LF for reliable matching +regsub -all {\r} $ls_output "" ls_output + +set expected_files [split $env(FILES_STR) " "] +foreach f $expected_files { + if {![regexp -line -- "^$f$" $ls_output]} { + puts "ERROR: missing file: $f" + exit 1 + } +} +send -- "bye\r" +expect eof +EOF +EXPECT_RESULT=$? + +echo "Cleaning up local files..." +rm -f -r $REMOTE_DIR/*_random + +if [ "$EXPECT_RESULT" -ne 0 ]; then + echo "SFTP connection test failed" + exit 1 +else + echo "SFTP connection test passed: all expected files are present" + + exit 0 +fi + + diff --git a/demo/sftp/std/testing/test_put_file_long.sh b/demo/sftp/std/testing/test_put_file_long.sh new file mode 100755 index 00000000..ac2879f0 --- /dev/null +++ b/demo/sftp/std/testing/test_put_file_long.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Test PUT a long file of 100MB +# It tests if the uploaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Cleaning the remote directory +rm -f -r $REMOTE_DIR/* + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("100MB_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=100 of=$BASE_DIR/100MB_random 2>/dev/null + + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf "put $BASE_DIR/%s\n" "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +DIFF_RESULT=0 +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + rm -f -r "$BASE_DIR"/${file} "$REMOTE_DIR"/${file} + else + ((DIFF_RESULT++)) + echo "FAIL: ${file}" + fi +done + +if [ "$DIFF_RESULT" -ne 0 ]; then + echo "$DIFF_RESULT files failed: Keeping file(s) for inspection" + exit "$DIFF_RESULT" +else + echo "Upload test Passed." + exit 0 +fi + diff --git a/demo/sftp/std/testing/test_put_files.sh b/demo/sftp/std/testing/test_put_files.sh new file mode 100755 index 00000000..a7074e2d --- /dev/null +++ b/demo/sftp/std/testing/test_put_files.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Test PUT a small files onto the server +# It tests if the uploaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Cleaning the remote directory +rm -f -r $REMOTE_DIR/* + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("512B_random" "16kB_random" "64kB_random" "65kB_random" "256kB_random" "1024kB_random" "2048kB_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$BASE_DIR/512B_random 2>/dev/null +dd if=/dev/random bs=1024 count=16 of=$BASE_DIR/16kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=64 of=$BASE_DIR/64kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=65 of=$BASE_DIR/65kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=256 of=$BASE_DIR/256kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=1024 of=$BASE_DIR/1024kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=2048 of=$BASE_DIR/2048kB_random 2>/dev/null + + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} -vvv << EOF +$(printf "put $BASE_DIR/%s\n" "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +DIFF_RESULT=0 +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + rm -f -r "$BASE_DIR"/${file} "$REMOTE_DIR"/${file} + else + ((DIFF_RESULT++)) + echo "FAIL: ${file}" + fi +done + +if [ "$DIFF_RESULT" -ne 0 ]; then + echo "$DIFF_RESULT files failed: Keeping file(s) for inspection" + exit "$DIFF_RESULT" +else + echo "Upload test Passed." + exit 0 +fi + diff --git a/demo/sftp/std/testing/test_stats_file.sh b/demo/sftp/std/testing/test_stats_file.sh new file mode 100755 index 00000000..6673b019 --- /dev/null +++ b/demo/sftp/std/testing/test_stats_file.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# This test checks the stats of a single file +# Run it from the project root directory or testing folder +# This script requires expect tool + +if ! command -v expect >/dev/null 2>&1; then + echo "Error: 'expect' is not installed or not in PATH." + echo "Please install it and run this test again." + exit 1 +fi + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Stats..." + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("512B_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$REMOTE_DIR/512B_random 2>/dev/null + +# # List files +# sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} << EOF +# $(printf 'ls -l %s\n' "${FILES[@]} | awk '{print $1, $9}'") +# bye +# EOF + +FILES_STR="${FILES[*]}" + +export REMOTE_HOST REMOTE_USER FILES_STR +expect << 'EOF' +set timeout 20 + +spawn sftp -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $env(REMOTE_USER)@$env(REMOTE_HOST) + +# Wait for sftp> prompt +expect { + -re {(?m)^sftp> ?$} {} + -re {(?i)password:} { + puts "ERROR: password prompt received" + exit 1 + } + -re {.+\n} { exp_continue } + timeout { + puts "ERROR: did not receive sftp prompt" + exit 1 + } + eof { + puts "ERROR: sftp terminated before showing prompt" + exit 1 + } +} + +send -- "ls -ln\r" +expect { + -re {(?ms)(.*)\r?\nsftp> ?$} { + set ls_output $expect_out(0,string) + } + timeout { + puts "ERROR: did not receive prompt after ls" + exit 1 + } + eof { + puts "ERROR: sftp terminated after ls" + exit 1 + } +} + +# Normalize CRLF -> LF +regsub -all {\r} $ls_output "" ls_output + +# Hardcoded expected values. If +set expected_name "512B_random" +set expected_perm "-rw-rw-r--" +set expected_uid "1000" +set expected_gid "1000" +set expected_size "512" + +set found 0 +foreach line [split $ls_output "\n"] { + set line [string trim $line] + if {$line eq ""} { continue } + if {[string match "ls -ln*" $line]} { continue } ;# echoed command + if {[string match "sftp>*" $line]} { continue } ;# prompt + if {[string match "total *" $line]} { continue } ;# ls header + + puts "Good candidate: <$line>" + + # Split into non-space fields: + # perms links uid gid size month day time-or-year name + set fields [regexp -all -inline {\S+} $line] + if {[llength $fields] < 9} { + puts "Skip: not enough fields: <$line>" + continue + } + + set perm [lindex $fields 0] + set uid [lindex $fields 2] + set gid [lindex $fields 3] + set size [lindex $fields 4] + set name [lindex $fields end] + + puts "Parsed: perm=$perm uid=$uid gid=$gid size=$size name=$name" + + if {$name ne $expected_name} { + puts "Skip: different filename: <$line>" + continue + } + + set found 1 + + if {$perm ne $expected_perm || $uid ne $expected_uid || $gid ne $expected_gid || $size ne $expected_size} { + puts "ERROR: stat mismatch for $expected_name" + puts " expected: perm=$expected_perm uid=$expected_uid gid=$expected_gid size=$expected_size" + puts " actual: perm=$perm uid=$uid gid=$gid size=$size" + exit 1 + } +} + +if {!$found} { + puts "ERROR: file $expected_name not found in ls output" + exit 1 +} else { + puts "Stats test passed: file $expected_name has expected permissions, ownership and size" + exit 0 +} + +send -- "bye\r" +expect eof +EOF +EXPECT_RESULT=$? + +if [ "$EXPECT_RESULT" -ne 0 ]; then + echo "SFTP stats test failed" + exit 1 +else + echo "SFTP stats test passed" + exit 0 +fi + +echo "Cleaning up local files..." +rm -f -r $REMOTE_DIR/*_random \ No newline at end of file diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml new file mode 100644 index 00000000..fe4e834e --- /dev/null +++ b/sftp/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sunset-sftp" +version = "0.1.2" +edition = "2024" + +[features] +# long paths support, which allows paths up to 4096 bytes, by default paths are limited to 256 bytes +long-paths-4096 = [] +long-paths-1024 = [] + +# Standard library support - enables std helpers +std = [] + +[dependencies] +sunset = { path = "../" } +sunset-async = { path = "../async" } +sunset-sshwire-derive = { path = "../sshwire-derive" } + + +embedded-io-async = "0.6" +num_enum = { version = "0.7.4", default-features = false } +paste = "1.0.15" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment +log = "0.4" +embassy-sync = "0.7.2" +embassy-futures = "0.1.2" diff --git a/sftp/src/lib.rs b/sftp/src/lib.rs new file mode 100644 index 00000000..e7158172 --- /dev/null +++ b/sftp/src/lib.rs @@ -0,0 +1,130 @@ +//! SFTP (SSH File Transfer Protocol) implementation for [`sunset`]. +//! +//! (Partially) Implements SFTP v3 as defined in [draft-ietf-secsh-filexfer-02](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02). +//! +//! **Work in Progress**: Currently focuses on file upload operations. +//! Long packets for requests other than writing and additional SFTP operations +//! are not yet implemented. `no_std` compatibility is intended but not +//! yet complete. Please see the roadmap and use this crate carefully. +//! +//! This crate implements a handler that, given a [`sunset::ChanHandle`] +//! a `sunset_async::SSHServer` and some auxiliary buffers, +//! can dispatch SFTP packets to a struct implementing [`crate::sftpserver::SftpServer`] trait. +//! +//! See example usage in the `../demo/sftd/std` directory for the intended usage +//! of this library. +//! +//! # Roadmap +//! +//! The following list is an opinionated collection of the points that should be +//! completed to provide growing functionality. +//! +//! ## Basic features +//! +//! - [x] [SFTP Protocol Initialization](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-4) (Only SFTP V3 supported) +//! - [x] [Canonicalizing the Server-Side Path Name](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.11) support +//! - [x] [Open, close](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +//! and [write](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +//! - [x] Directory [Browsing](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7) +//! - [x] File [read](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4), +//! - [] File [write](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) with conditions. See [Server Channel Window length is reduced to zero when long data is sent from server to client](https://github.com/mkj/sunset/issues/40), +//! - [x] File [stats](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) +//! +//! ## Minimal features for convenient usability +//! +//! - [ ] [Removing files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.5) +//! - [ ] [Renaming files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.5) +//! - [ ] [Creating directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.6) +//! - [ ] [Removing directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.6) +//! +//! ## Extended features +//! +//! - [ ] [Append, create and truncate files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +//! files +//! - [ ] [Reading](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) +//! files attributes +//! - [ ] [Setting](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.9) files attributes +//! - [ ] [Dealing with Symbolic links](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.10) +//! - [ ] [Vendor Specific](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-8) +//! request and responses + +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +mod opaquefilehandle; +mod proto; +mod sftperror; +mod sftphandler; +mod sftpserver; +mod sftpsink; +mod sftpsource; + +// Main calling point for the library provided that the user implements +// a [`server::SftpServer`]. +// +// Please see basic usage at `../demo/sftd/std` +pub use sftphandler::SftpHandler; + +/// Source of SFTP packets +/// +/// Used to decode SFTP packets from a byte slice +pub use sftpsource::SftpSource; + +/// Structures and types used to add the details for the target system +/// Related to the implementation of the [`server::SftpServer`], which +/// is meant to be instantiated by the user and passed to [`SftpHandler`] +/// and has the task of executing client requests in the underlying system +pub mod server { + + pub use crate::sftpserver::DirReply; + pub use crate::sftpserver::ReadReply; + pub use crate::sftpserver::ReadStatus; + pub use crate::sftpserver::SftpOpResult; + pub use crate::sftpserver::SftpServer; + /// Helpers to reduce error prone tasks and hide some details that + /// add complexity when implementing an [`SftpServer`] + pub mod helpers { + pub use crate::sftpserver::helpers::*; + + #[cfg(feature = "std")] + pub use crate::sftpserver::DirEntriesCollection; + #[cfg(feature = "std")] + pub use crate::sftpserver::get_file_attrs; + } + pub use crate::sftpsink::SftpSink; + pub use sunset::sshwire::SSHEncode; + + pub use crate::proto::MAX_REQUEST_LEN; +} + +/// Handles and helpers used by the [`sftpserver::SftpServer`] trait implementer +pub mod handles { + pub use crate::opaquefilehandle::InitWithSeed; + pub use crate::opaquefilehandle::OpaqueFileHandle; + pub use crate::opaquefilehandle::OpaqueFileHandleManager; + pub use crate::opaquefilehandle::PathFinder; +} + +/// SFTP Protocol types and structures +pub mod protocol { + pub use crate::proto::Attrs; + pub use crate::proto::FileHandle; + pub use crate::proto::Filename; + pub use crate::proto::Name; + pub use crate::proto::NameEntry; + pub use crate::proto::PFlags; + pub use crate::proto::PathInfo; + pub use crate::proto::SftpPacket; + pub use crate::proto::StatusCode; + /// Constants that might be useful for SFTP developers + pub mod constants { + pub use crate::proto::MAX_NAME_ENTRY_SIZE; + } +} + +/// Errors and results used in this crate +pub mod error { + pub use crate::sftperror::SftpError; + pub use crate::sftperror::SftpResult; +} diff --git a/sftp/src/opaquefilehandle.rs b/sftp/src/opaquefilehandle.rs new file mode 100644 index 00000000..cde4aa7d --- /dev/null +++ b/sftp/src/opaquefilehandle.rs @@ -0,0 +1,72 @@ +use crate::protocol::FileHandle; + +use sunset::sshwire::WireResult; + +/// This is the trait with the required methods for interoperability between different opaque file handles +/// used in SFTP transactions +pub trait OpaqueFileHandle: + Sized + Clone + core::hash::Hash + PartialEq + Eq + core::fmt::Debug +{ + /// Creates a new `OpaqueFileHandleTrait` copying the content of the `FileHandle` + fn try_from(file_handle: &FileHandle<'_>) -> WireResult; + + /// Returns a FileHandle pointing to the data in the `OpaqueFileHandleTrait` Implementation + fn into_file_handle(&self) -> FileHandle<'_>; +} + +/// Used to standardize finding a path within the HandleManager +/// +/// Must be implemented by the private handle structure to allow the `OpaqueHandleManager` to look for the path of the file itself +pub trait PathFinder { + /// Helper function to find elements stored in the HandleManager that matches the give path + fn matches(&self, path: &Self) -> bool; + + /// gets the path as a reference + fn get_path_ref(&self) -> &str; +} + +/// Used in the `OpaqueFileHandleManager` to generate a Key (OpaqueFileHandle) from a seed +pub trait InitWithSeed: Sized { + /// The error type used for the implementation of `init_with_seed` useful to harmonize the error handling of the `OpaqueFileHandleManager` implementation + type Err; + + /// Creates a new instance using a given string slice as `seed` which + /// content should not clearly related to the seed + fn init_with_seed(s: &str) -> Result; +} + +/// This trait is used to manage the OpaqueFileHandles (K) together with the private handle (V) that contains the details of the file internally stored in the system +/// +/// The SFTP module user is not required to use it but instead is a suggestion for an exchangeable +/// trait that facilitates structuring the store and retrieve of 'OpaqueFileHandleTrait' (K), +/// together with a private handle type or structure (V) that will contains all the details internally stored for the given file. +/// +/// The only requisite for v is that implements PathFinder, which in fact is another suggested helper to allow the `OpaqueHandleManager` +/// to look for the file path. +pub trait OpaqueFileHandleManager +where + K: OpaqueFileHandle + InitWithSeed, + V: PathFinder, +{ + /// The error used for all the trait members returning an error + type Err; + + /// Given the private_handle, stores it and return an opaque file handle + /// + /// Returns an error if the private_handle has a matching path as obtained from `PathFinder` + /// + /// Salt has been added to allow the user to add a factor that will mask how the opaque handle is generated + fn insert(&mut self, private_handle: V, salt: &str) -> Result; + + /// + fn remove(&mut self, opaque_handle: &K) -> Option; + + /// Returns true if the opaque handle exist + fn opaque_handle_exist(&self, opaque_handle: &K) -> bool; + + /// given the opaque_handle returns a reference to the associated private handle + fn get_private_as_mut_ref(&mut self, opaque_handle: &K) -> Option<&mut V>; + + /// given the opaque_handle returns a reference to the associated private handle + fn get_private_as_ref(&self, opaque_handle: &K) -> Option<&V>; +} diff --git a/sftp/src/proto.rs b/sftp/src/proto.rs new file mode 100644 index 00000000..e53eb0af --- /dev/null +++ b/sftp/src/proto.rs @@ -0,0 +1,1142 @@ +use crate::sftpsource::SftpSource; + +use sunset::sshwire::{ + BinString, SSHDecode, SSHEncode, SSHEncodeEnum, SSHSink, SSHSource, TextString, + WireError, WireResult, +}; +use sunset_sshwire_derive::{SSHDecode, SSHEncode}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; +use num_enum::FromPrimitive; +use paste::paste; + +/// SFTP Minimum packet length is 9 bytes corresponding with `SSH_FXP_INIT` +#[allow(unused)] +pub const SFTP_MINIMUM_PACKET_LEN: usize = 9; + +#[allow(unused)] +pub const SFTP_FIELD_LEN_INDEX: usize = 0; +/// SFTP packets length field us u32 +#[allow(unused)] +pub const SFTP_FIELD_LEN_LENGTH: usize = 4; +/// SFTP packets have the packet type after a u32 length field +#[allow(unused)] +pub const SFTP_FIELD_ID_INDEX: usize = 4; +/// SFTP packets ID length is 1 byte +#[allow(unused)] +pub const SFTP_FIELD_ID_LEN: usize = 1; +/// SFTP packets start with the length field + +/// SFTP packets have the packet request id after field id +#[allow(unused)] +pub const SFTP_FIELD_REQ_ID_INDEX: usize = 5; +/// SFTP packets ID length is 1 byte +#[allow(unused)] +pub const SFTP_FIELD_REQ_ID_LEN: usize = 4; +/// SFTP packets start with the length field + +// SSH_FXP_WRITE SFTP Packet definition used to decode long packets that do not fit in one buffer + +/// SFTP SSH_FXP_WRITE Packet cannot be shorter than this (len:4+pnum:1+rid:4+hand:4+0+data:4+0 bytes = 17 bytes) [draft-ietf-secsh-filexfer-02](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +// pub const SFTP_MINIMUM_WRITE_PACKET_LEN: usize = 17; + +#[allow(unused)] +/// SFTP SSH_FXP_WRITE Packet request id field index [draft-ietf-secsh-filexfer-02](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +pub const SFTP_WRITE_REQID_INDEX: usize = 5; + +/// SFTP SSH_FXP_WRITE Packet handle field index [draft-ietf-secsh-filexfer-02](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +// pub const SFTP_WRITE_HANDLE_INDEX: usize = 9; + +/// Considering the definition in [Section 7](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +/// for handle maximum length +pub const _SSH_FXP_HANDLE_MAX_LEN: u32 = 256; + +/// The maximum size for full paths is only limited by the u32 where ssh strings lengths are contained. This causes that different platforms use different maximum path lengths. +/// We need to make a choice in this implementation. Since it is targeting embedded devices I am going to set it short, since influence the length of the [[requestHolder]] that needs to be allocated +/// to compose fragmented requests. +#[cfg(not(any(feature = "long-paths-4096", feature = "long-paths-1024")))] +pub const MAX_PATH_LEN: usize = 256; +#[cfg(feature = "long-paths-1024")] +pub const MAX_PATH_LEN: usize = 1024; // PATH_MAX for macOS +#[cfg(feature = "long-paths-4096")] +pub const MAX_PATH_LEN: usize = 4096; // Linux glibc PATH_MAX is typically 4096 bytes + +/// Maximum request size, considering [[MAX_PATH_LEN]] but not counting the data payload. +/// At this moment in time, the longest request is `ssh_fxp_open` +pub const MAX_REQUEST_LEN: usize = 4 + MAX_PATH_LEN // Filename string + + 4 // PFlags (u32) + + 32; // Attrs (Max 32Bytes not counting extensions) + +/// Considering the definition in [Section 7](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +/// for `SSH_FXP_READDIR` +/// +/// (4 + 256) bytes for filename, (4 + 0) bytes for empty long filename and 72 bytes for the attributes ( 32/4*7 + 64/4 * 1 = 72) +pub const MAX_NAME_ENTRY_SIZE: usize = 4 + MAX_PATH_LEN + 4 + 72; + +// TODO is utf8 enough, or does this need to be an opaque binstring? +/// See [SSH_FXP_NAME in Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Filename<'a>(TextString<'a>); + +impl<'a> From<&'a str> for Filename<'a> { + fn from(s: &'a str) -> Self { + Filename(TextString(s.as_bytes())) + } +} + +// TODO standardize the encoding of filenames as str +impl<'a> Filename<'a> { + /// + pub fn as_str(&self) -> Result<&'a str, WireError> { + core::str::from_utf8(self.0.0).map_err(|_| WireError::BadString) + } +} + +/// An opaque handle that is used by the server to identify an open +/// file or folder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SSHEncode, SSHDecode)] +pub struct FileHandle<'a>(pub BinString<'a>); + +// ========================== Initialization =========================== + +/// The reference implementation we are working on is 3, this is, https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02 +pub const SFTP_VERSION: u32 = 3; + +/// The SFTP version of the client +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct InitVersionClient { + // No ReqId for SSH_FXP_INIT + pub version: u32, + // TODO variable number of ExtPair +} + +/// The lowers SFTP version from the client and the server +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct InitVersionLowest { + // No ReqId for SSH_FXP_VERSION + pub version: u32, + // TODO variable number of ExtPair +} + +// ============================= Requests ============================== + +/// Used for `ssh_fxp_open` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Open<'a> { + /// The relative or absolute path of the file to be open + pub filename: Filename<'a>, + /// File [permissions flags](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) + pub pflags: PFlags, + /// Initial attributes for the file + pub attrs: Attrs, +} + +/// Flags for Open RequestFor more information see [Opening, creating and closing files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +#[derive(Debug, FromPrimitive, PartialEq)] +#[repr(u32)] +#[allow(non_camel_case_types, missing_docs)] +pub enum PFlags { + //#[sshwire(variant = "ssh_fx_read")] + SSH_FXF_READ = 0x00000001, + //#[sshwire(variant = "ssh_fx_write")] + SSH_FXF_WRITE = 0x00000002, + //#[sshwire(variant = "ssh_fx_append")] + SSH_FXF_APPEND = 0x00000004, + //#[sshwire(variant = "ssh_fx_creat")] + SSH_FXF_CREAT = 0x00000008, + //#[sshwire(variant = "ssh_fx_trunk")] + SSH_FXF_TRUNC = 0x00000010, + //#[sshwire(variant = "ssh_fx_excl")] + SSH_FXF_EXCL = 0x00000020, + //#[sshwire(unknown)] + #[num_enum(catch_all)] + Multiple(u32), +} + +impl<'de> SSHDecode<'de> for PFlags { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(PFlags::from(u32::dec(s)?)) + } +} + +// This is prone to errors if we update PFlags enum: Unlikely +impl From<&PFlags> for u32 { + fn from(value: &PFlags) -> Self { + match value { + PFlags::SSH_FXF_READ => 0x00000001, + PFlags::SSH_FXF_WRITE => 0x00000002, + PFlags::SSH_FXF_APPEND => 0x00000004, + PFlags::SSH_FXF_CREAT => 0x00000008, + PFlags::SSH_FXF_TRUNC => 0x00000010, + PFlags::SSH_FXF_EXCL => 0x00000020, + PFlags::Multiple(value) => *value, + } + } +} +// TODO: Implement an SSHEncode attribute for enums to encode them in a given numeric format +impl SSHEncode for PFlags { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let numeric_value: u32 = self.into(); + numeric_value.enc(s) + } +} + +/// Used for `ssh_fxp_open` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct OpenDir<'a> { + /// The relative or absolute path of the directory to be open + pub dirname: Filename<'a>, +} + +/// Used for `ssh_fxp_close` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Close<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder to be closed. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_read` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Read<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, + /// The offset for the read operation + pub offset: u64, + /// The number of bytes to be retrieved + pub len: u32, +} + +/// Used for `ssh_fxp_readdir` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct ReadDir<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_write` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Write<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, + /// The offset for the read operation + pub offset: u64, + + /// The data length to be written. Given that it can be arbitrary long, the data is not decoded + /// Instead the data_len is used in [[SftpHandler.Process]] to generate SftpServer.Write calls + pub data_len: u32, +} + +/// Used for `ssh_fxp_lstat` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8). +/// LSTAT does not follow symbolic links +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct LStat<'a> { + /// The path of the element which stats are to be retrieved + pub file_path: TextString<'a>, +} + +/// Used for `ssh_fxp_lstat` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8). +/// STAT does follow symbolic links +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Stat<'a> { + /// The path of the element which stats are to be retrieved + pub file_path: TextString<'a>, +} + +// ============================= Responses ============================= + +/// Used for `ssh_fxp_realpath` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.11). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct PathInfo<'a> { + /// The path + pub path: TextString<'a>, +} + +/// Used for `ssh_fxp_status` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Status<'a> { + /// See [`StatusCode`] for possible codes + pub code: StatusCode, + /// An extra message + pub message: TextString<'a>, + /// A language tag as defined by [Tags for the Identification of Languages](https://datatracker.ietf.org/doc/html/rfc1766) + pub lang: TextString<'a>, +} + +/// Used for `ssh_fxp_handle` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, Clone, Copy, PartialEq, Eq, SSHEncode, SSHDecode)] +pub struct Handle<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_data` [responses](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Data<'a> { + /// raw data + pub data: BinString<'a>, +} + +/// This is the encoded length for the [`Data`] Sftp Response. +/// +/// This considers the Packet type (1), the request ID (4), and the data string +/// length (4) +/// +/// - It excludes explicitly length field for the SftpPacket +/// - It excludes explicitly length of the data string content +/// +/// It is defined a single source of truth for what is the length for the +/// encoded [`SftpPacket::Data`] variant +/// +/// See [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +pub(crate) const ENCODED_SSH_FXP_DATA_MIN_LENGTH: u32 = 1 + 4 + 4; + +/// Struct to hold `SSH_FXP_NAME` response. +/// See [SSH_FXP_NAME in Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct NameEntry<'a> { + /// Is a file name being returned + pub filename: Filename<'a>, + /// longname is an undefined text line like "ls -l", + /// SHOULD NOT be used. + pub _longname: Filename<'a>, + /// Attributes for the file entry + /// + /// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) + /// for more information. + pub attrs: Attrs, +} + +/// This is the encoded length for the Name Sftp Response. +/// +/// This considers the Packet type (1), the Request Id (4) and +/// count of [`NameEntry`] that will follow +/// +/// It excludes the length of [`NameEntry`] explicitly +/// +/// It is defined a single source of truth for what is the length for the +/// encoded [`SftpPacket::Name`] variant +/// +/// See [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +pub(crate) const ENCODED_BASE_NAME_SFTP_PACKET_LENGTH: u32 = 9; + +// TODO Will a Vector be an issue for no_std? +// Maybe we should migrate this to heapless::Vec and let the user decide +// the number of elements via features flags? +/// This is the first part of the `SSH_FXP_NAME` response. It includes +/// only the count of [`NameEntry`] items that follow this Name +/// +/// After encoding or decoding [`Name`], [`NameEntry`] must be encoded or +/// decoded `count` times +/// A collection of [`NameEntry`] used for [ssh_fxp_name responses](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug)] +// pub struct Name<'a>(pub Vec>); +pub struct Name { + /// Number of [`NameEntry`] items that follow this Name + pub count: u32, +} + +impl<'de> SSHDecode<'de> for Name { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + let count = u32::dec(s)? as u32; + + // let mut names = Vec::with_capacity(count); + + // for _ in 0..count { + // names.push(NameEntry::dec(s)?); + // } + + Ok(Name { count }) + } +} + +impl SSHEncode for Name { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + self.count.enc(s) + // (self.0.len() as u32).enc(s)?; + + // for element in self.0.iter() { + // element.enc(s)?; + // } + // Ok(()) + } +} + +// Requests/Responses data types + +#[derive(Debug, SSHEncode, SSHDecode, Clone, Copy, PartialEq, Eq)] +pub struct ReqId(pub u32); + +/// For more information see [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, FromPrimitive)] +#[repr(u32)] +#[allow(non_camel_case_types, missing_docs)] +pub enum StatusCode { + // #[sshwire(variant = "ssh_fx_ok")] + SSH_FX_OK = 0, + // #[sshwire(variant = "ssh_fx_eof")] + SSH_FX_EOF = 1, + // #[sshwire(variant = "ssh_fx_no_such_file")] + SSH_FX_NO_SUCH_FILE = 2, + // #[sshwire(variant = "ssh_fx_permission_denied")] + SSH_FX_PERMISSION_DENIED = 3, + // #[sshwire(variant = "ssh_fx_failure")] + SSH_FX_FAILURE = 4, + // #[sshwire(variant = "ssh_fx_bad_message")] + SSH_FX_BAD_MESSAGE = 5, + // #[sshwire(variant = "ssh_fx_no_connection")] + SSH_FX_NO_CONNECTION = 6, + // #[sshwire(variant = "ssh_fx_connection_lost")] + SSH_FX_CONNECTION_LOST = 7, + // #[sshwire(variant = "ssh_fx_unsupported")] + SSH_FX_OP_UNSUPPORTED = 8, + // #[sshwire(unknown)] + #[num_enum(catch_all)] + Other(u32), +} + +impl<'de> SSHDecode<'de> for StatusCode { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(StatusCode::from(u32::dec(s)?)) + } +} + +// This is prone to errors if we update StatusCode enum: Unlikely to change +impl From<&StatusCode> for u32 { + fn from(value: &StatusCode) -> Self { + match value { + StatusCode::SSH_FX_OK => 0, + StatusCode::SSH_FX_EOF => 1, + StatusCode::SSH_FX_NO_SUCH_FILE => 2, + StatusCode::SSH_FX_PERMISSION_DENIED => 3, + StatusCode::SSH_FX_FAILURE => 4, + StatusCode::SSH_FX_BAD_MESSAGE => 5, + StatusCode::SSH_FX_NO_CONNECTION => 6, + StatusCode::SSH_FX_CONNECTION_LOST => 7, + StatusCode::SSH_FX_OP_UNSUPPORTED => 8, + StatusCode::Other(value) => *value, + } + } +} +// TODO: Implement an SSHEncode attribute for enums to encode them in a given numeric format +impl SSHEncode for StatusCode { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let numeric_value: u32 = self.into(); + numeric_value.enc(s) + } +} + +// TODO: Implement extensions. Low in priority +/// Provided to provide a mechanism to implement extensions +// #[derive(Debug, SSHEncode, SSHDecode)] +// pub struct ExtPair<'a> { +// pub name: &'a str, +// pub data: BinString<'a>, +// } + +/// Files attributes to describe Files as SFTP v3 specification +/// +/// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) +/// for more information. +#[allow(missing_docs)] +#[derive(Debug, Default, PartialEq)] +pub struct Attrs { + pub size: Option, + pub uid: Option, + pub gid: Option, + pub permissions: Option, + pub atime: Option, + pub mtime: Option, + pub ext_count: Option, + // TODO extensions +} + +/// For more information see [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) +#[repr(u32)] +#[allow(non_camel_case_types)] +pub enum AttrsFlags { + SSH_FILEXFER_ATTR_SIZE = 0x01, + SSH_FILEXFER_ATTR_UIDGID = 0x02, + SSH_FILEXFER_ATTR_PERMISSIONS = 0x04, + SSH_FILEXFER_ATTR_ACMODTIME = 0x08, + SSH_FILEXFER_ATTR_EXTENDED = 0x80000000, +} +impl core::ops::AddAssign for u32 { + fn add_assign(&mut self, other: AttrsFlags) { + *self |= other as u32; + } +} + +impl core::ops::BitAnd for u32 { + type Output = u32; + + fn bitand(self, rhs: AttrsFlags) -> Self::Output { + self & rhs as u32 + } +} + +impl Attrs { + /// Obtains the flags for the values stored in the [`Attrs`] struct. + /// + /// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) + /// for more information. + pub fn flags(&self) -> u32 { + let mut flags: u32 = 0; + if self.size.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_SIZE + } + if self.uid.is_some() || self.gid.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_UIDGID + } + if self.permissions.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_PERMISSIONS + } + if self.atime.is_some() || self.mtime.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_ACMODTIME + } + // TODO Implement extensions + // if self.ext_count.is_some() { + // flags += AttrsFlags::SSH_FILEXFER_ATTR_EXTENDED + // } + + flags + } +} + +impl SSHEncode for Attrs { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + self.flags().enc(s)?; + + // IMPORTANT: Order matters in the encoding/decoding since it will be interpreted together with the flags + if let Some(value) = self.size.as_ref() { + value.enc(s)? + } + if let Some(value) = self.uid.as_ref() { + value.enc(s)? + } + if let Some(value) = self.gid.as_ref() { + value.enc(s)? + } + if let Some(value) = self.permissions.as_ref() { + value.enc(s)? + } + if let Some(value) = self.atime.as_ref() { + value.enc(s)? + } + if let Some(value) = self.mtime.as_ref() { + value.enc(s)? + } + // TODO Implement extensions + // if let Some(value) = self.ext_count.as_ref() { value.enc(s)? } + + Ok(()) + } +} + +impl<'de> SSHDecode<'de> for Attrs { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + let mut attrs = Attrs::default(); + let flags = u32::dec(s)? as u32; + if flags & AttrsFlags::SSH_FILEXFER_ATTR_SIZE != 0 { + attrs.size = Some(u64::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_UIDGID != 0 { + attrs.uid = Some(u32::dec(s)?); + attrs.gid = Some(u32::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_PERMISSIONS != 0 { + attrs.permissions = Some(u32::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_ACMODTIME != 0 { + attrs.atime = Some(u32::dec(s)?); + attrs.mtime = Some(u32::dec(s)?); + } + // TODO Implement extensions + // if flags & AttrsFlags::SSH_FILEXFER_ATTR_EXTENDED != 0{ + + Ok(attrs) + } +} + +macro_rules! sftpmessages { + ( + init: { + $( ( $init_message_num:tt, + $init_packet_variant:ident, + $init_packet_type:ty, + $init_ssh_fxp_name:literal + ), + )* + }, + request: { + $( ( $request_message_num:tt, + $request_packet_variant:ident, + $request_packet_type:ty, + $request_ssh_fxp_name:literal + ), + )* + }, + response: { + $( ( $response_message_num:tt, + $response_packet_variant:ident, + $response_packet_type:ty, + $response_ssh_fxp_name:literal + ), + )* + }, + ) => { + paste! { + /// Represent a subset of the SFTP packet types defined by draft-ietf-secsh-filexfer-02 + #[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] + #[repr(u8)] + #[allow(non_camel_case_types)] + pub enum SftpNum { + $( + [<$init_ssh_fxp_name:upper>] = $init_message_num, + )* + + $( + [<$request_ssh_fxp_name:upper>] = $request_message_num, + )* + + $( + [<$response_ssh_fxp_name:upper>] = $response_message_num, + )* + + #[num_enum(catch_all)] + Other(u8), + } + impl SSHEncode for SftpNum { + fn enc(&self, _: &mut dyn SSHSink) -> WireResult<()> { + Ok(()) + } + } + + impl SSHEncodeEnum for SftpNum { + fn variant_name(&self) -> WireResult<&'static str> { + let r = match self { + $( + Self::[<$init_ssh_fxp_name:upper>] => $init_ssh_fxp_name, + )* + $( + Self::[<$request_ssh_fxp_name:upper>] => $request_ssh_fxp_name, + )* + $( + Self::[<$response_ssh_fxp_name:upper>] => $response_ssh_fxp_name, + )* + Self::Other(_) => { + return Err(WireError::UnknownVariant); + } + }; + #[allow(unreachable_code)] Ok(r) + } + } + + } // paste + + + impl<'de> SSHDecode<'de> for SftpNum { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(SftpNum::from(u8::dec(s)?)) + } + } + paste!{ + impl From for u8{ + fn from(sftp_num: SftpNum) -> u8 { + match sftp_num { + $( + SftpNum::[<$init_ssh_fxp_name:upper>] => $init_message_num, + )* + $( + SftpNum::[<$request_ssh_fxp_name:upper>] => $request_message_num, + )* + $( + SftpNum::[<$response_ssh_fxp_name:upper>] => $response_message_num, + )* + + SftpNum::Other(number) => number // Other, not in the enum definition + + } + } + + } + + } //paste + + impl SftpNum { + fn is_init(&self) -> bool { + (1..=1).contains(&(u8::from(self.clone()))) + } + + pub(crate) fn is_request(&self) -> bool { + // TODO SSH_FXP_EXTENDED + (3..=20).contains(&(u8::from(self.clone()))) + } + + fn is_response(&self) -> bool { + // TODO SSH_FXP_EXTENDED_REPLY + (100..=105).contains(&(u8::from(self.clone()))) + ||(2..=2).contains(&(u8::from(self.clone()))) + } + } + + + /// Top level SSH packet enum + /// + /// It helps identifying the SFTP Packet type and handling it accordingly + /// This is done using the SFTP field type + #[derive(Debug)] + pub enum SftpPacket<'a> { + $( + #[doc = concat!("Initialization packet: ", $init_ssh_fxp_name)] + $init_packet_variant($init_packet_type), + )* + $( + #[doc = concat!("Request packet: ", $request_ssh_fxp_name)] + $request_packet_variant(ReqId, $request_packet_type), + )* + $( + #[doc = concat!("Response packet: ", $response_ssh_fxp_name)] + $response_packet_variant(ReqId, $response_packet_type), + )* + + } + + + impl SSHEncode for SftpPacket<'_> { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let t = u8::from(self.sftp_num()); + t.enc(s)?; + match self { + // eg + // SftpPacket::KexInit(p) => { + // ... + $( + SftpPacket::$init_packet_variant(p) => { + p.enc(s)? + } + )* + $( + SftpPacket::$request_packet_variant(id, p) => { + id.enc(s)?; + p.enc(s)? + } + )* + $( + SftpPacket::$response_packet_variant(id, p) => { + id.enc(s)?; + p.enc(s)? + } + )* + }; + Ok(()) + } + } + + paste!{ + + + impl<'a> SSHDecode<'a> for SftpPacket<'a> + { + fn dec(s: &mut S) -> WireResult + where S: SSHSource<'a> { + let packet_type_number = u8::dec(s)?; + + let packet_type = SftpNum::from(packet_type_number); + + let decoded_packet = match packet_type { + $( + SftpNum::[<$init_ssh_fxp_name:upper>] => { + + let inner_type = <$init_packet_type>::dec(s)?; + SftpPacket::$init_packet_variant(inner_type) + + }, + )* + $( + SftpNum::[<$request_ssh_fxp_name:upper>] => { + let req_id = ::dec(s)?; + let inner_type = <$request_packet_type>::dec(s)?; + SftpPacket::$request_packet_variant(req_id,inner_type) + + }, + )* + $( + SftpNum::[<$response_ssh_fxp_name:upper>] => { + let req_id = ::dec(s)?; + let inner_type = <$response_packet_type>::dec(s)?; + SftpPacket::$response_packet_variant(req_id,inner_type) + + }, + )* + _ => return Err(WireError::UnknownPacket { number: packet_type_number }) + }; + Ok(decoded_packet) + } + } + } // paste + + impl<'a> SftpPacket<'a> { + /// Maps `SpecificPacketVariant` to `message_num` + pub fn sftp_num(&self) -> SftpNum { + match self { + // eg + // SftpPacket::Open(_) => { + // .. + $( + SftpPacket::$init_packet_variant(_) => { + + SftpNum::from($init_message_num as u8) + } + )* + $( + SftpPacket::$request_packet_variant(_,_) => { + + SftpNum::from($request_message_num as u8) + } + )* + $( + SftpPacket::$response_packet_variant(_,_) => { + + SftpNum::from($response_message_num as u8) + } + )* + } + } + + // TODO Maybe change WireResult -> SftpResult and SSHSink to SftpSink? + // This way I have more internal details and can return a Error::bug() if required + /// Encode a request. + /// + /// Used by a SFTP client. Does not include the length field. + pub fn encode_request(&self, id: ReqId, s: &mut dyn SSHSink) -> WireResult<()> { + if !self.sftp_num().is_request() { + return Err(WireError::PacketWrong) + // return Err(Error::bug()) + // I understand that it would be a bad call of encode_response and + // therefore a bug, bug Error::bug() is not compatible with WireResult + } + + // packet type + self.sftp_num().enc(s)?; + // request ID + id.0.enc(s)?; + // contents + self.enc(s) + } + + // TODO Maybe change WireResult -> SftpResult and SSHSource to SftpSource? + // This way I have more internal details and can return a more appropriate error if required + /// Decode a response. + /// + /// Used by a SFTP client. Does not include the length field. + pub fn decode_response<'de>(s: &mut SftpSource<'de>) -> WireResult + where + // S: SftpSource<'de>, + 'a: 'de, // 'a must outlive 'de and 'de must outlive 'a so they have matching lifetimes + 'de: 'a + { + let packet_length = u32::dec(s)?; + trace!("Packet field len = {:?}, buffer len = {:?}", packet_length, s.remaining()); + match Self::dec(s) { + Ok(sftp_packet)=> { + if !sftp_packet.sftp_num().is_response() + { + Err(WireError::PacketWrong) + }else{ + Ok(sftp_packet) + + } + }, + Err(e) => { + Err(e) + } + } + } + + + /// Decode a request or initialization packets + /// + /// Used by a SFTP server. Does not include the length field. + /// + /// It will fail if the received packet is a response, no valid or incomplete packet + pub fn decode_request<'de>(s: &mut SftpSource<'de>) -> WireResult + where + // S: SftpSource<'de>, + 'a: 'de, // 'a must outlive 'de and 'de must outlive 'a so they have matching lifetimes + 'de: 'a + { + let packet_length = u32::dec(s)?; + trace!("Packet field len = {:?}, buffer len = {:?}", packet_length, s.remaining()); + + match Self::dec(s) { + Ok(sftp_packet)=> { + if (!sftp_packet.sftp_num().is_request() + && !sftp_packet.sftp_num().is_init()) + { + Err(WireError::PacketWrong) + }else{ + Ok(sftp_packet) + + } + }, + Err(e) => { + match e { + WireError::UnknownPacket{..} if !s.packet_fits() => Err(WireError::RanOut), + _ => Err(e) + } + + } + } + } + + /// Decode a a packet without checking if it is request or response + /// + /// Used by a SFTP server. Does not include the length field. + /// + /// It will fail if the received packet is a response, no valid or incomplete packet + pub fn decode<'de>(s: &mut SftpSource<'de>) -> WireResult + where + // S: SftpSource<'de>, + 'a: 'de, // 'a must outlive 'de and 'de must outlive 'a so they have matching lifetimes + 'de: 'a + { + let packet_length = u32::dec(s)?; + trace!("Packet field len = {:?}, buffer remaining = {:?}", packet_length, s.remaining()); + Self::dec(s) + } + + // TODO Maybe change WireResult -> SftpResult and SSHSink to SftpSink? + // This way I have more internal details and can return a Error::bug() if required + /// Encode a response. + /// + /// Used by a SFTP server. Does not include the length field. + /// + /// Fails if the encoded SFTP Packet is not a response + pub fn encode_response(&self, s: &mut dyn SSHSink) -> WireResult<()> { + + if !self.sftp_num().is_response() { + return Err(WireError::PacketWrong) + // return Err(Error::bug()) + // I understand that it would be a bad call of encode_response and + // therefore a bug, bug Error::bug() is not compatible with WireResult + } + + self.enc(s) + } + + } + + $( + impl<'a> From<$init_packet_type> for SftpPacket<'a> { + fn from(s: $init_packet_type) -> SftpPacket<'a> { + SftpPacket::$init_packet_variant(s) //find me + } + } + )* + $( + /// **Warning**: No Sequence Id can be infered from a Packet Type + impl<'a> From<$request_packet_type> for SftpPacket<'a> { + fn from(s: $request_packet_type) -> SftpPacket<'a> { + debug!("Casting from {:?} to SftpPacket cannot set Request Id",$request_ssh_fxp_name); + SftpPacket::$request_packet_variant(ReqId(0), s) + } + } + )* + $( + /// **Warning**: No Sequence Id can be infered from a Packet Type + impl<'a> From<$response_packet_type> for SftpPacket<'a> { + fn from(s: $response_packet_type) -> SftpPacket<'a> { + debug!("Casting from {:?} to SftpPacket cannot set Request Id",$response_ssh_fxp_name); + SftpPacket::$response_packet_variant(ReqId(0), s) + } + } + )* + + }; // main macro + +} // sftpmessages macro + +sftpmessages! [ + + init:{ + (1, Init, InitVersionClient, "ssh_fxp_init"), + (2, Version, InitVersionLowest, "ssh_fxp_version"), + }, + + request: { + (3, Open, Open<'a>, "ssh_fxp_open"), + (4, Close, Close<'a>, "ssh_fxp_close"), + (5, Read, Read<'a>, "ssh_fxp_read"), + (6, Write, Write<'a>, "ssh_fxp_write"), + (7, LStat, LStat<'a>, "ssh_fxp_lstat"), + (11, OpenDir, OpenDir<'a>, "ssh_fxp_opendir"), + (12, ReadDir, ReadDir<'a>, "ssh_fxp_readdir"), + (16, PathInfo, PathInfo<'a>, "ssh_fxp_realpath"), + (17, Stat, Stat<'a>, "ssh_fxp_stat"), + // When adding requests, review MAX_REQUEST_LEN in order to adjust its value + }, + + response: { + (101, Status, Status<'a>, "ssh_fxp_status"), + (102, Handle, Handle<'a>, "ssh_fxp_handle"), + (103, Data, Data<'a>, "ssh_fxp_data"), + (104, Name, Name, "ssh_fxp_name"), + (105, Attrs, Attrs, "ssh_fxp_attrs"), + }, +]; + +#[cfg(test)] +mod proto_tests { + use super::*; + use crate::server::SftpSink; + + // TODO: There are always more test that can be done + + #[cfg(test)] + extern crate std; + #[cfg(test)] + use std::println; + + #[test] + fn test_data_roundtrip() { + let data_slice = b"Hello, world!".as_slice(); + let mut buff = [0u8; 512]; + let data_packet = + SftpPacket::Data(ReqId(10), Data { data: BinString(data_slice) }); + + let mut sink = SftpSink::new(&mut buff); + data_packet.encode_response(&mut sink).expect("Failed to encode response"); + println!( + "data_packet encoded_len = {:?}, encoded = {:?}", + sink.payload_len(), + sink.payload_slice() + ); + let mut source = SftpSource::new(sink.used_slice()); + println!("source = {:?}", source); + + match SftpPacket::decode_response(&mut source) { + Ok(SftpPacket::Data(req_id, data)) => { + assert_eq!(req_id, ReqId(10)); + assert_eq!(data.data, BinString(data_slice)); + } + Ok(other) => panic!("Expected Data packet, got: {:?}", other), + Err(e) => panic!("Failed to decode packet: {:?}", e), + } + } + + #[test] + fn test_status_encoding() { + let mut buf = [0u8; 256]; + let mut sink = SftpSink::new(&mut buf); + let status_packet = SftpPacket::Status( + ReqId(16), + Status { + code: StatusCode::SSH_FX_EOF, + message: "A".into(), + lang: "en-US".into(), + }, + ); + + let expected_status_packet_slice: [u8; 27] = [ + 0, 0, 0, 23, // Packet len + 101, // Packet type + 0, 0, 0, 16, // ReqId + 0, 0, 0, 1, // Status code: SSH_FX_EOF + 0, 0, 0, 1, // string message length + 65, // string message content + 0, 0, 0, 5, // string lang length + 101, 110, 45, 85, 83, // string lang content + ]; + + let _ = status_packet.encode_response(&mut sink); + + assert_eq!(&expected_status_packet_slice, sink.used_slice()); + } + + #[test] + fn test_attributes_roundtrip() { + let mut buff = [0u8; MAX_NAME_ENTRY_SIZE]; + let attr_read_only = Attrs { + size: Some(1), + uid: Some(2), + gid: Some(3), + permissions: Some(222), + atime: Some(4), + mtime: Some(5), + ext_count: None, + }; + + let mut sink = SftpSink::new(&mut buff); + attr_read_only.enc(&mut sink).unwrap(); + println!( + "attr_read_only encoded_len = {:?}, encoded = {:?}", + sink.payload_len(), + sink.payload_slice() + ); + let mut source = SftpSource::new(sink.payload_slice()); + println!("source = {:?}", source); + + let a_r = Attrs::dec(&mut source); + match a_r { + Ok(attrs) => { + println!("source = {:?}", attrs); + assert_eq!(attr_read_only, attrs); + } + Err(e) => panic!("The attributes could not be decoded: {:?}", e), + } + } + + #[test] + fn test_packet_open_reading() { + let buff_open_read = [ + 0u8, 0, 0, + 58, // Len + 3, // SftpPacket + 0, 0, 0, + 4, // ReqId + 0, 0, 0, + 41, // Text String len + 46, 47, 100, 101, 109, 111, 47, 115, 102, + 116, // file Path + 112, 47, 115, 116, 100, 47, 116, 101, 115, 116, 105, 110, 103, 47, 111, + 117, 116, 47, 46, 47, 53, 49, 50, 66, 95, 114, 97, 110, 100, 111, + 109, // and 41 + 0, 0, 0, + 1, // PFlags: 1u32 == SSSH_FXF_READ + 0, 0, 0, + 0, // Attrib flags == 0 No flags, no attributes + ]; + + let mut source = SftpSource::new(&buff_open_read); + println!("source = {:?}", source); + + match SftpPacket::decode_request(&mut source) { + Ok(SftpPacket::Open(_req_id, open)) => { + assert_eq!(PFlags::SSH_FXF_READ, open.pflags); + } + Ok(other) => panic!("Expected Open packet, got: {:?}", other), + Err(e) => panic!("Failed to decode packet: {:?}", e), + } + } +} diff --git a/sftp/src/sftperror.rs b/sftp/src/sftperror.rs new file mode 100644 index 00000000..e19c253f --- /dev/null +++ b/sftp/src/sftperror.rs @@ -0,0 +1,98 @@ +use crate::protocol::StatusCode; + +use crate::sftphandler::requestholder::RequestHolderError; +use sunset::Error as SunsetError; +use sunset::sshwire::WireError; + +use core::convert::From; +use log::warn; + +/// Errors that are specific to this SFTP lib +#[derive(Debug)] +pub enum SftpError { + /// The SFTP server has not been initialised. No SFTP version has been + /// establish + NotInitialized, + /// An `SSH_FXP_INIT` packet was received after the server was already + /// initialized + AlreadyInitialized, + /// A packet could not be decoded as it was malformed + MalformedPacket, + /// The server does not have an implementation for the current request. + /// Some possible causes are: + /// + /// - The request has not been handled by an [`crate::sftpserver::SftpServer`] + /// - Long request which its handling was not implemented + NotSupported, + /// The connection has been closed by the client + ClientDisconnected, + /// The [`crate::sftpserver::SftpServer`] failed doing an IO operation + FileServerError(StatusCode), + // A RequestHolder instance throw an error. See [`crate::requestholder::RequestHolderError`] + /// A RequestHolder instance threw an error. See `RequestHolderError` + RequestHolderError(RequestHolderError), + /// A variant containing a [`WireError`] + WireError(WireError), + /// A variant containing a [`SunsetError`] + SunsetError(SunsetError), +} + +impl From for SftpError { + fn from(value: WireError) -> Self { + SftpError::WireError(value) + } +} + +impl From for SftpError { + fn from(value: SunsetError) -> Self { + SftpError::SunsetError(value) + } +} + +impl From for SftpError { + fn from(value: StatusCode) -> Self { + SftpError::FileServerError(value) + } +} + +impl From for SftpError { + fn from(value: RequestHolderError) -> Self { + SftpError::RequestHolderError(value) + } +} +// impl From for SftpError { +// fn from(value: FileServerError) -> Self { +// SftpError::FileServerError(value) +// } +// } + +impl From for WireError { + fn from(value: SftpError) -> Self { + match value { + SftpError::WireError(wire_error) => wire_error, + _ => WireError::PacketWrong, + } + } +} + +impl From for SunsetError { + fn from(value: SftpError) -> Self { + match value { + SftpError::SunsetError(error) => error, + SftpError::WireError(wire_error) => wire_error.into(), + SftpError::NotInitialized + | SftpError::NotSupported + | SftpError::AlreadyInitialized + | SftpError::MalformedPacket + | SftpError::RequestHolderError(_) + | SftpError::FileServerError(_) => { + warn!("Casting error loosing information: {:?}", value); + sunset::error::PacketWrong.build() + } + SftpError::ClientDisconnected => SunsetError::ChannelEOF, + } + } +} + +/// result specific to this SFTP lib +pub type SftpResult = Result; diff --git a/sftp/src/sftphandler/mod.rs b/sftp/src/sftphandler/mod.rs new file mode 100644 index 00000000..988cc09f --- /dev/null +++ b/sftp/src/sftphandler/mod.rs @@ -0,0 +1,6 @@ +pub mod requestholder; +mod sftphandler; +mod sftpoutputchannelhandler; + +pub use sftphandler::SftpHandler; +pub use sftpoutputchannelhandler::SftpOutputProducer; diff --git a/sftp/src/sftphandler/requestholder.rs b/sftp/src/sftphandler/requestholder.rs new file mode 100644 index 00000000..fe4ac47f --- /dev/null +++ b/sftp/src/sftphandler/requestholder.rs @@ -0,0 +1,369 @@ +use crate::{ + proto::{MAX_REQUEST_LEN, SftpNum, SftpPacket}, + sftpsource::SftpSource, +}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; +use sunset::sshwire::WireError; + +#[derive(Debug)] +pub enum RequestHolderError { + /// The slice to hold is too long + NoRoom, + /// The slice holder is keeping a slice already. Consider cleaning + Busy, + /// The slice holder is empty + Empty, + /// There is not enough data in the slice we are trying to add. we need more data + RanOut, + /// The Packet held is not a request + NotRequest, + /// WireError + WireError(WireError), +} + +impl From for RequestHolderError { + fn from(value: WireError) -> Self { + RequestHolderError::WireError(value) + } +} + +pub(crate) type RequestHolderResult = Result; + +/// Helper struct to manage short fragmented requests that have been +/// received in consecutive read operations +/// +/// For requests exceeding the length of buffers other techniques, such +/// as composing them into multiple request, might help reducing the +/// required buffer sizes. This is recommended for restricted environments. +/// +/// The intended use for this RequestHolder is (in order): +/// - `new`: Initialize the struct with a slice that will keep the +/// request in memory +/// +/// - `try_hold`: load the data for an incomplete request +/// +/// - `try_append_for_valid_request`: append more data from another +/// slice to complete the request +/// +/// - `try_get_ref`: returns a reference to the portion of the slice +/// containing a request +/// +/// - `reset`: reset counters and flags to allow `try_hold` a new request +/// +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RequestHolder<'a> { + /// The buffer used to contain the data for the request + buffer: &'a mut [u8; MAX_REQUEST_LEN], + /// The index of the last byte in the buffer containing usable data + buffer_fill_index: usize, + /// Number of bytes appended in a previous `try_hold` or `try_append_for_valid_request` slice + appended: usize, + /// Used to mark when the structure is holding data + busy: bool, +} + +impl<'a> RequestHolder<'a> { + /// The buffer will be used to hold a full request. Choose a + /// reasonable size for this buffer. + pub(crate) fn new(buffer: &'a mut [u8; MAX_REQUEST_LEN]) -> Self { + RequestHolder { + buffer: buffer, + buffer_fill_index: 0, + busy: false, + appended: 0, + } + } + + /// Uses the internal buffer to store a copy of the provided slice + /// + /// The definition of `try_hold` and `try_append_slice` separately + /// is deliberated to follow an order in composing the held request + /// + /// Increases the `appended()` counter + /// + /// returns: + /// + /// - Ok(usize): the number of bytes read from the slice + /// + /// - `Err(Busy)`: If there has been a call to `try_hold` without a call to `reset` + pub(crate) fn try_hold(&mut self, slice: &[u8]) -> RequestHolderResult { + if self.busy { + return Err(RequestHolderError::Busy); + } + + self.busy = true; + self.try_append_slice(slice)?; + let read_in = self.appended(); + self.appended = 0; + Ok(read_in) + } + + /// Resets the structure allowing it to hold a new request. + /// + /// Resets the `appended()` counter. + /// + /// Will **clear** the previous data from the buffer. + pub(crate) fn reset(&mut self) -> () { + self.busy = false; + self.buffer_fill_index = 0; + self.appended = 0; + self.buffer.fill(0); + } + + /// Appends a byte at a time to the internal buffer and tries to + /// decode a request + /// + /// Reset and increase the `appended()` counter. + /// + /// **Returns**: + /// + /// - `Ok(())`: A valid request is held now + /// + /// - `Err(NotRequest)`: The decoded packet is not a request + /// + /// - `Err(RanOut)`: Not enough bytes in the slice to add a single byte + /// + /// - `Err(NoRoom)`: The internal buffer is full + /// + /// - `Err(Empty)`: If the structure has not been loaded with `try_hold` + /// + pub(crate) fn try_appending_for_valid_request( + &mut self, + slice_in: &[u8], + ) -> RequestHolderResult { + debug!( + "try_appending_for_valid_request: self = {:?}\n\ + Space left = {:?}\n\ + Length of slice to append from = {:?}", + self, + self.remaining_len(), + slice_in.len() + ); + + if !self.busy { + error!("Request Holder is not busy"); + return Err(RequestHolderError::Empty); + } + + self.appended = 0; // reset appended bytes counter. Try_append_slice will increase it + + if self.is_full() { + error!("Request Holder is full"); + return Err(RequestHolderError::NoRoom); + } + + if let Some(request) = self.valid_request() { + debug!("The request holder already contained a valid request"); + return Ok(request.sftp_num()); + } + + let mut slice = slice_in; + loop { + debug!( + "try_appending_for_valid_request: Slice length {:?}", + slice.len() + ); + if slice.len() > 0 { + self.try_append_slice(&[slice[0]])?; + slice = &slice[1..]; + let mut source = SftpSource::new(self.try_get_ref()?); + if let Ok(pt) = source.peek_packet_type() { + if !pt.is_request() { + error!("The request candidate is not a request: {pt:?}"); + return Err(RequestHolderError::NotRequest); + } + } else { + continue; + }; + match SftpPacket::decode_request(&mut source) { + Ok(request) => { + debug!("Request is {:?}", request); + return Ok(request.sftp_num()); + } + Err(WireError::RanOut) => { + if slice.len() == 0 { + return Err(RequestHolderError::RanOut); + } + } + Err(WireError::NoRoom) => { + return Err(RequestHolderError::NoRoom); + } + Err(WireError::PacketWrong) => { + return Err(RequestHolderError::NotRequest); + } + Err(e) => return Err(RequestHolderError::WireError(e)), + } + } else { + return Err(RequestHolderError::RanOut); + } + } + } + + pub(crate) fn valid_request(&self) -> Option> { + if !self.busy { + return None; + } + let Ok(buffer_ref) = self.try_get_ref() else { + return None; + }; + + let mut source = SftpSource::new(buffer_ref); + + let Ok(request) = SftpPacket::decode_request(&mut source) else { + return None; + }; + return Some(request); + } + + /// Gets a reference to the slice that it is holding + pub(crate) fn try_get_ref(&self) -> RequestHolderResult<&[u8]> { + if self.busy { + debug!( + "Returning reference to: {:?}", + &self.buffer[..self.buffer_fill_index] + ); + Ok(&self.buffer[..self.buffer_fill_index]) + } else { + Err(RequestHolderError::Empty) + } + } + + pub(crate) fn is_full(&mut self) -> bool { + self.buffer_fill_index == self.buffer.len() + } + + #[allow(unused)] + /// Returns true if it has a slice in its buffer + pub(crate) fn is_busy(&self) -> bool { + self.busy + } + + /// Returns the bytes appened in the last call to + /// [`RequestHolder::try_append_for_valid_request`] or + /// [`RequestHolder::try_append_for_valid_header`] or + /// [`RequestHolder::try_append_slice`] or + /// [`RequestHolder::try_appending_single_byte`] + pub(crate) fn appended(&self) -> usize { + self.appended + } + + /// Appends a slice to the internal buffer. Requires the buffer to + /// be busy by using `try_hold` first + /// + /// Increases the `appended` counter but does not reset it + /// + /// Returns: + /// + /// - `Ok(())`: the slice was appended + /// + /// - `Err(Empty)`: If the structure has not been loaded with `try_hold` + /// + /// - `Err(NoRoom)`: The internal buffer is full but there is not a full valid request in the buffer + fn try_append_slice(&mut self, slice: &[u8]) -> RequestHolderResult<()> { + if slice.len() == 0 { + warn!("try appending a zero length slice"); + return Ok(()); + } + if !self.busy { + return Err(RequestHolderError::Empty); + } + + let in_len = slice.len(); + if in_len > self.remaining_len() { + return Err(RequestHolderError::NoRoom); + } + debug!("Adding: {:?}", slice); + + self.buffer[self.buffer_fill_index..self.buffer_fill_index + in_len] + .copy_from_slice(slice); + + self.buffer_fill_index += in_len; + debug!( + "RequestHolder: index = {:?}, slice = {:?}", + self.buffer_fill_index, + self.try_get_ref()? + ); + self.appended += in_len; + Ok(()) + } + + /// Returns the number of bytes unused at the end of the buffer, + /// this is, the remaining length + fn remaining_len(&self) -> usize { + self.buffer.len() - self.buffer_fill_index + } +} + +#[cfg(test)] +mod local_test { + use super::*; + + #[cfg(test)] + extern crate std; + #[cfg(test)] + use std::println; + + fn get_buffer_with_valid_request() -> [u8; 85] { + [ + 0, 0, 128, 25, 6, 0, 0, 0, 23, 0, 0, 0, 4, 249, 67, 81, 122, 0, 0, 0, 0, + 0, 9, 128, 0, 0, 0, 128, 0, 116, 101, 115, 116, 105, 110, 103, 47, 111, + 117, 116, 47, 49, 48, 48, 77, 66, 95, 114, 97, 110, 100, 111, 109, 0, 0, + 0, 26, 0, 0, 0, 4, 0, 0, 1, 164, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ] + } + #[test] + fn valid_request_uses_filled_data() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + assert!(rh.valid_request().is_none()); + } + + #[test] + fn try_appending_for_valid_request_uses_filled_data() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + assert!(rh.try_appending_for_valid_request(&buff_data[5..10]).is_err()); + } + + #[test] + fn try_appending_for_valid_request_works() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + println!("{buff_data:?}"); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + println!("before appending{rh:?}"); + let appending = rh.try_appending_for_valid_request(&buff_data[5..]); + // println!("{appending:?}",); + println!("after appending {rh:?}"); + assert!(appending.is_ok()); + } +} diff --git a/sftp/src/sftphandler/sftphandler.rs b/sftp/src/sftphandler/sftphandler.rs new file mode 100644 index 00000000..339e61a2 --- /dev/null +++ b/sftp/src/sftphandler/sftphandler.rs @@ -0,0 +1,757 @@ +use crate::error::SftpError; +use crate::handles::OpaqueFileHandle; +use crate::proto::{ + self, InitVersionClient, InitVersionLowest, LStat, MAX_REQUEST_LEN, ReqId, + SFTP_VERSION, SftpNum, SftpPacket, Stat, StatusCode, +}; +use crate::server::{DirReply, ReadReply}; +use crate::sftperror::SftpResult; +use crate::sftphandler::requestholder::{RequestHolder, RequestHolderError}; +use crate::sftphandler::sftpoutputchannelhandler::{ + SftpOutputPipe, SftpOutputProducer, +}; +use crate::sftpserver::SftpServer; +use crate::sftpsource::SftpSource; + +use embassy_futures::select::select; +use sunset::Error as SunsetError; +use sunset::sshwire::{SSHSource, WireError}; +use sunset_async::ChanInOut; + +use core::u32; +use embedded_io_async::Read; +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// FSM for handling sftp requests during [`SftpHandler::process`] +#[derive(Default, Debug, PartialEq, Eq)] +enum HandlerState { + /// The handle is not been initialized. + /// if the client receivs an Init packet it will process it. + #[default] + Uninitialized, + /// The handle is ready to process requests. No request pending + /// A new packet will be evaluated to be process as: + /// - a regular request + /// - fragment (More data is needed) + /// - long request (It does not fit in the buffers and segmenting + /// strategies are used) + Idle, + /// The client has received a request and will decide how to process it. + /// Use the self.incomplete_request_holder + ProcessRequest { sftp_num: SftpNum }, + /// There is a fragmented request and more bytes are needed + /// Use the self.incomplete_request_holder + ProcessFragment, + /// A request, with a length over the incoming buffer capacity is being + /// processed. + /// + /// E.g. a write request with size exceeding the + /// buffer size: Processing this request will require to be split + /// into multiple write actions + ProcessWriteRequest { offset: u64, remaining_data: u32 }, + + /// Used to clear an invalid buffer in cases where there is still + /// data to be process but no longer required + ClearBuffer { data: usize }, +} + +/// Process the raw buffers in and out from a subsystem channel decoding +/// request and encoding responses +/// +/// It will delegate request to an [`crate::sftpserver::SftpServer`] +/// implemented by the library +/// user taking into account the local system details. +/// +/// The compiler time constant `BUFFER_OUT_SIZE` is used to define the +/// size of the output buffer for the subsystem [`Embassy-sync::pipe`] used +/// to send responses safely across the instantiated structure. +/// +pub struct SftpHandler<'a, T, S, const BUFFER_OUT_SIZE: usize> +where + T: OpaqueFileHandle, + S: SftpServer<'a, T>, +{ + /// Holds the internal state if the SFTP handle + state: HandlerState, + + /// The local SFTP File server implementing the basic SFTP requests + /// defined by [`crate::sftpserver::SftpServer`] + file_server: &'a mut S, + + // /// Use to process SFTP Write packets that have been received + // /// partially and the remaining is expected in successive buffers + // partial_write_request_tracker: Option>, + /// Used to handle received buffers that do not hold a complete request [`SftpPacket`] + request_holder: RequestHolder<'a>, + + /// Marker to keep track of the OpaqueFileHandle type + _marker: core::marker::PhantomData, +} + +impl<'a, T, S, const BUFFER_OUT_SIZE: usize> SftpHandler<'a, T, S, BUFFER_OUT_SIZE> +where + T: OpaqueFileHandle, + S: SftpServer<'a, T>, +{ + /// Creates a new instance of the structure. + /// + /// Requires: + /// + /// - `file_server` (implementing [`crate::sftpserver::SftpServer`] ): to execute + /// the request in the local system + /// - `request_buffer`: used to deal with fragmented + /// packets during [`SftpHandler::process_loop`] + pub fn new( + file_server: &'a mut S, + request_buffer: &'a mut [u8; MAX_REQUEST_LEN], + ) -> Self { + SftpHandler { + file_server, + state: HandlerState::default(), + request_holder: RequestHolder::new(request_buffer), + _marker: core::marker::PhantomData, + } + } + + /// Take the [`ChanInOut`] and locks, Processing all the request from stdio until + /// an EOF is received + pub async fn process_loop( + &mut self, + stdio: ChanInOut<'a>, + buffer_in: &mut [u8], + ) -> SftpResult<()> { + let (mut chan_in, chan_out) = stdio.split(); + + let mut sftp_output_pipe = SftpOutputPipe::::new(); + + let (mut output_consumer, output_producer) = + sftp_output_pipe.split(chan_out)?; + + let output_consumer_loop = output_consumer.receive_task(); + + let processing_loop = async { + loop { + trace!("SFTP: About to read bytes from SSH Channel"); + let lr: usize = match chan_in.read(buffer_in).await { + Ok(lr) => lr, + Err(e) => match e { + SunsetError::NoRoom {} => { + error!("SSH channel is full"); + continue; + } + _ => return Err(e.into()), + }, + }; + + debug!("SFTP <---- received: {:?} bytes", lr); + trace!("SFTP <---- received: {:?}", &buffer_in[0..lr]); + if lr == 0 { + debug!("client disconnected"); + return Err(SftpError::ClientDisconnected); + } + + self.process(&buffer_in[0..lr], &output_producer).await?; + } + #[allow(unreachable_code)] + SftpResult::Ok(()) + }; + match select(processing_loop, output_consumer_loop).await { + embassy_futures::select::Either::First(r) => { + error!("Processing returned: {:?}", r); + r + } + embassy_futures::select::Either::Second(r) => { + error!("Output consumer returned: {:?}", r); + r + } + } + } + + /// - Decodes the buffer_in request + /// - Process the request delegating + /// operations to a [`SftpServer`] implementation + /// - Serializes an answer in `output_producer` + /// + async fn process( + &mut self, + buffer_in: &[u8], + output_producer: &SftpOutputProducer<'_, BUFFER_OUT_SIZE>, + ) -> SftpResult<()> { + /* + Possible scenarios: + - Init: The init handshake has to be performed. Only Init packet is accepted. NAV(Idle) + - handshake?: The client has received an Init packet and is processing it. NAV( Init, Idle) + - Idle: Ready to process request. No request pending. In this point. NAV(ProcessRequest, Fragment) + - Fragment: There is a fragmented request and more data is needed. NAV(ProcessRequest, ProcessLongRequest) + - ProcessRequest: The client has received a request and is processing it. NAV(Idle) + - ProcessLongRequest: The client has received a request that cannot fit in the buffer. Special treatment is required. NAV(Idle) + */ + let mut buf = buffer_in; + + trace!("Received {:} bytes to process", buf.len()); + + // We used `run_another_loop` to bypass the buf len check in + // cases where we need to process data held + // TODO: Fix this pattern + let mut skip_checking_buffer = false; + trace!("Entering loop to process the full received buffer"); + while skip_checking_buffer || buf.len() > 0 { + debug!( + "<=======================[ SFTP Process State: {:?} ]=======================> Buffer remaining: {}", + self.state, + buf.len() + ); + skip_checking_buffer = false; + match &self.state { + HandlerState::ProcessWriteRequest { + offset, + remaining_data: data_len, + } => { + if let Some(request) = self.request_holder.valid_request() { + if let SftpPacket::Write(req_id, write) = request { + let used = (*data_len as usize).min(buf.len()); + let remaining_data = *data_len - used as u32; + + let data = &buf[..used]; + buf = &buf[used..]; + match self + .file_server + .write(&T::try_from(&write.handle)?, *offset, data) + .await + { + Ok(_) => { + if remaining_data == 0 { + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_OK, + "", + ) + .await?; + trace!("Still in buffer: {buf:?}"); + self.state = HandlerState::Idle; + } else { + self.state = + HandlerState::ProcessWriteRequest { + offset: *offset + (used as u64), + remaining_data, + }; + } + } + Err(e) => { + error!("SFTP write thrown: {:?}", e); + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "error writing", + ) + .await?; + self.state = HandlerState::ClearBuffer { + data: remaining_data as usize, + }; + } + }; + } else { + todo!("Wrong transition? Uncontrolled for now"); + } + } else { + todo!("Wrong transition? Uncontrolled for now"); + } + } + HandlerState::Uninitialized => { + debug!("Creating a source: buf_len = {:?}", buf.len()); + let mut source = SftpSource::new(&buf); + + match SftpPacket::decode_request(&mut source) { + Ok(request) => match request { + SftpPacket::Init(InitVersionClient { + version: SFTP_VERSION, + }) => { + debug!( + "Accepted initialization request: {:?}", + request + ); + output_producer + .send_packet(&SftpPacket::Version( + InitVersionLowest { version: SFTP_VERSION }, + )) + .await?; + buf = &buf[buf.len() - source.remaining()..]; + self.state = HandlerState::Idle; + } + SftpPacket::Init(init_version_client) => { + error!( + "Incompatible SFTP Version: {:?} is not {SFTP_VERSION:?}", + &init_version_client + ); + return Err(SftpError::NotSupported); + } + _ => { + error!( + "Wrong SFTP Packet before Init or incompatible version: {request:?}" + ); + return Err(SftpError::NotInitialized); + } + }, + Err(e) => { + error!("Malformed SFTP Packet before Init: {e:?}"); + return Err(SftpError::MalformedPacket); + } // Err(e) => { + // error!("Malformed SFTP Packet before Init: {e:?}"); + // return Err(SftpError::MalformedPacket); + // } + } + } + HandlerState::Idle => { + self.request_holder.reset(); + debug!("Creating a source: buf_len = {:?}", buf.len()); + let mut source = SftpSource::new(&buf); + trace!("source: {source:?}"); + + match SftpPacket::decode_request(&mut source) { + Ok(request) => { + debug!("Got a valid request {:?}", request.sftp_num()); + self.request_holder.try_hold(&source.buffer_used())?; + + // We got the request. Moving on to process it before deserializing more + // data + skip_checking_buffer = true; + self.state = HandlerState::ProcessRequest { + sftp_num: request.sftp_num(), + }; + // TODO Wasteful. Will have to decode the request again. Maybe hold it? + buf = &buf[buf.len() - source.remaining()..]; + } + Err(WireError::RanOut) => { + debug!("source: {source:?}"); + let rl = self + .request_holder + .try_hold(&source.consume_all())?; + + buf = &buf[buf.len() - source.remaining()..]; + debug!( + "Incomplete packet. request holder initialized with {rl:?} bytes" + ); + self.state = HandlerState::ProcessFragment; + } + Err(WireError::UnknownPacket { number }) => { + error!("Unknown packet: {number}"); + output_producer + .send_status( + ReqId( + source + .peak_packet_req_id() + .unwrap_or(u32::MAX), + ), + StatusCode::SSH_FX_OP_UNSUPPORTED, + "", + ) + .await?; + buf = &buf[buf.len() - source.remaining()..]; + debug!( + "Unknown Packet. clearing the buffer in place since it filts" + ); + } + Err(WireError::PacketWrong) => { + error!("Not a request: "); + output_producer + .send_status( + ReqId( + source + .peak_packet_req_id() + .unwrap_or(u32::MAX), + ), + StatusCode::SSH_FX_BAD_MESSAGE, + "Not a request", + ) + .await?; + } + Err(e) => { + error!("Unexpected error: Bug!"); + return Err(SftpError::WireError(e)); + } + }; + } + HandlerState::ProcessFragment => { + match self.request_holder.try_appending_for_valid_request(&buf) { + Ok(sftp_num) => { + let used = self.request_holder.appended(); + debug!( + "{used:?} bytes added. We got a complete request: {sftp_num:?}:: {:?}", + self.request_holder + ); + debug!( + "Request: {:?}", + self.request_holder.valid_request() + ); + buf = &buf[used..]; + self.state = HandlerState::ProcessRequest { sftp_num } + } + Err(RequestHolderError::RanOut) => { + let used = self.request_holder.appended(); + buf = &buf[used..]; + debug!( + "{used:?} bytes added. Will keep adding \ + until we hold a valid request" + ); + } + Err(RequestHolderError::NoRoom) => { + error!( + "Could not complete the request. holding buffer is full" + ); + return Err(SunsetError::Bug.into()); + } + Err(e) => { + error!("{e:?}"); + return Err(e.into()); + } + } + } + HandlerState::ProcessRequest { .. } => { + // At this point the assumption is that the request holder will contain + // a full valid request (Lets call this an invariant) + + if let Some(request) = self.request_holder.valid_request() { + if !request.sftp_num().is_request() { + error!( + "Unexpected SftpPacket: {:?}", + request.sftp_num() + ); + return Err(SunsetError::BadUsage {}.into()); + } + match request { + // SftpPacket::Init(init_version_client) => todo!(), + // SftpPacket::Version(init_version_lowest) => todo!(), + SftpPacket::Read(req_id, ref read) => { + debug!("Read request: {:?}", request); + + let mut reply = + ReadReply::new(req_id, output_producer); + if let Err(error) = self + .file_server + .read( + &T::try_from(&read.handle)?, + read.offset, + read.len, + &mut reply, + ) + .await + { + error!("Error reading data: {:?}", error); + if let SftpError::FileServerError(status) = error + { + output_producer + .send_status( + req_id, + status, + "Could not list attributes", + ) + .await?; + } else { + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "Could not list attributes", + ) + .await?; + } + }; + + match reply.read_diff() { + diff if diff > 0 => { + debug!( + "ReadReply not completed after read operation. Still need to send {} bytes", + diff + ); + return Err(SunsetError::Bug.into()); + } + diff if diff < 0 => { + error!( + "ReadReply has sent more data than announced: {} bytes extra", + -diff + ); + return Err(SunsetError::Bug.into()); + } + _ => {} + } + + self.state = HandlerState::Idle; + } + SftpPacket::LStat(req_id, LStat { file_path: path }) => { + match self + .file_server + .attrs(false, path.as_str()?) + .await + { + Ok(attrs) => { + debug!( + "List stats for {} is {:?}", + path, attrs + ); + + output_producer + .send_packet(&SftpPacket::Attrs( + req_id, attrs, + )) + .await?; + } + Err(status) => { + error!( + "Error listing stats for {}: {:?}", + path, status + ); + output_producer + .send_status( + req_id, + status, + "Could not list attributes", + ) + .await?; + } + }; + self.state = HandlerState::Idle; + } + SftpPacket::Stat(req_id, Stat { file_path: path }) => { + match self + .file_server + .attrs(true, path.as_str()?) + .await + { + Ok(attrs) => { + debug!( + "List stats for {} is {:?}", + path, attrs + ); + + output_producer + .send_packet(&SftpPacket::Attrs( + req_id, attrs, + )) + .await?; + } + Err(status) => { + error!( + "Error listing stats for {}: {:?}", + path, status + ); + output_producer + .send_status( + req_id, + status, + "Could not list attributes", + ) + .await?; + } + }; + self.state = HandlerState::Idle; + } + SftpPacket::ReadDir(req_id, read_dir) => { + let mut reply = + DirReply::new(req_id, output_producer); + if let Err(status) = self + .file_server + .readdir( + &T::try_from(&read_dir.handle)?, + &mut reply, + ) + .await + { + error!("Open failed: {:?}", status); + + output_producer + .send_status( + req_id, + status, + "Error Reading Directory", + ) + .await?; + }; + match reply.read_diff() { + diff if diff > 0 => { + debug!( + "DirReply not completed after read operation. Still need to send {} bytes", + diff + ); + return Err(SunsetError::Bug.into()); + } + diff if diff < 0 => { + error!( + "DirReply has sent more data than announced: {} bytes extra", + -diff + ); + return Err(SunsetError::Bug.into()); + } + _ => {} + } + self.state = HandlerState::Idle; + } + SftpPacket::OpenDir(req_id, open_dir) => { + match self + .file_server + .opendir(open_dir.dirname.as_str()?) + .await + { + Ok(opaque_file_handle) => { + let response = SftpPacket::Handle( + req_id, + proto::Handle { + handle: opaque_file_handle + .into_file_handle(), + }, + ); + output_producer + .send_packet(&response) + .await?; + } + Err(status_code) => { + error!("Open failed: {:?}", status_code); + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "", + ) + .await?; + } + }; + self.state = HandlerState::Idle; + } + SftpPacket::Close(req_id, close) => { + match self + .file_server + .close(&T::try_from(&close.handle)?) + .await + { + Ok(_) => { + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_OK, + "", + ) + .await?; + } + Err(e) => { + error!("SFTP Close thrown: {:?}", e); + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "Could not Close the handle", + ) + .await?; + } + } + self.state = HandlerState::Idle; + } + SftpPacket::Write(_, write) => { + debug!("Got write: {:?}", write); + self.state = HandlerState::ProcessWriteRequest { + offset: write.offset, + remaining_data: write.data_len, + }; + } + SftpPacket::Open(req_id, open) => { + match self + .file_server + .open(open.filename.as_str()?, &open.pflags) + .await + { + Ok(opaque_file_handle) => { + let response = SftpPacket::Handle( + req_id, + proto::Handle { + handle: opaque_file_handle + .into_file_handle(), + }, + ); + output_producer + .send_packet(&response) + .await?; + } + Err(status_code) => { + error!("Open failed: {:?}", status_code); + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "", + ) + .await?; + } + }; + self.state = HandlerState::Idle; + } + SftpPacket::PathInfo(req_id, path_info) => { + match self + .file_server + .realpath(path_info.path.as_str()?) + .await + { + Ok(name_entry) => { + let mut dir_reply = + DirReply::new(req_id, output_producer); + let encoded_len = + crate::sftpserver::helpers::get_name_entry_len(&name_entry)?; + debug!( + "PathInfo encoded length: {:?}", + encoded_len + ); + trace!( + "PathInfo Response content: {:?}", + encoded_len + ); + dir_reply + .send_header(1, encoded_len) + .await?; + dir_reply.send_item(&name_entry).await?; + if dir_reply.read_diff() != 0 { + error!( + "PathInfo reply not completed after sending the only item" + ); + return Err(SunsetError::Bug.into()); + } + } + Err(code) => { + output_producer + .send_status(req_id, code, "") + .await?; + } + } + self.state = HandlerState::Idle; + } + SftpPacket::Init(..) + | SftpPacket::Version(..) + | SftpPacket::Status(..) + | SftpPacket::Handle(..) + | SftpPacket::Data(..) + | SftpPacket::Name(..) + | SftpPacket::Attrs(..) => { + error!( + "Unexpected SftpPacket in ProcessRequest state: {:?}", + request.sftp_num() + ); + return Err(SunsetError::BadUsage {}.into()); + } + } + } else { + return Err(SunsetError::bug().into()); + } + } + HandlerState::ClearBuffer { data } => { + if *data == 0 { + self.state = HandlerState::Idle; + } else { + buf = &buf[(*data).min(buf.len())..] + } + } + } + trace!("Process will check buf len {:?}", buf.len()); + } + debug!("Whole buffer processed. Getting more data"); + Ok(()) + } +} diff --git a/sftp/src/sftphandler/sftpoutputchannelhandler.rs b/sftp/src/sftphandler/sftpoutputchannelhandler.rs new file mode 100644 index 00000000..3843e8e8 --- /dev/null +++ b/sftp/src/sftphandler/sftpoutputchannelhandler.rs @@ -0,0 +1,195 @@ +use crate::error::{SftpError, SftpResult}; +use crate::proto::{ReqId, SftpPacket, Status, StatusCode}; +use crate::server::SftpSink; + +use embassy_sync::mutex::Mutex; +use sunset_async::ChanOut; + +use embassy_sync::pipe::{Pipe, Reader as PipeReader, Writer as PipeWriter}; +use embedded_io_async::Write; +use sunset_async::SunsetRawMutex; + +use log::{debug, error, trace}; + +type CounterMutex = Mutex; + +pub struct SftpOutputPipe { + pipe: Pipe, + counter_send: CounterMutex, + counter_recv: CounterMutex, + splitted: bool, +} + +/// M: SunsetSunsetRawMutex +impl SftpOutputPipe { + /// Creates an empty SftpOutputPipe. + /// The output channel will be consumed during the split call + /// + /// Usage: + /// + /// let output_pipe = SftpOutputPipe::::new(); + /// + pub fn new() -> Self { + SftpOutputPipe { + pipe: Pipe::new(), + counter_send: Mutex::::new(0), + counter_recv: Mutex::::new(0), + splitted: false, + } + } + + /// Get a Consumer and Producer pair so the producer can send data to the + /// output channel without mutable borrows. + /// + /// The [`SftpOutputConsumer`] needs to be running to write data to the + /// [`ChanOut`] + /// + /// ## Lifetimes + /// The lifetime indicates that the lifetime of self, ChanOut and the + /// consumer and producer are the same. I chose this because if the ChanOut + /// is closed, there is no point on having a pipe outliving it. + pub fn split<'a>( + &'a mut self, + ssh_chan_out: ChanOut<'a>, + ) -> SftpResult<(SftpOutputConsumer<'a, N>, SftpOutputProducer<'a, N>)> { + if self.splitted { + return Err(SftpError::AlreadyInitialized); + } + self.splitted = true; + let (reader, writer) = self.pipe.split(); + Ok(( + SftpOutputConsumer { reader, ssh_chan_out, counter: &self.counter_recv }, + SftpOutputProducer { writer, counter: &self.counter_send }, + )) + } +} + +/// Consumer that takes ownership of [`ChanOut`]. It pipes the data received +/// from a [`PipeReader`] into the channel +pub(crate) struct SftpOutputConsumer<'a, const N: usize> { + reader: PipeReader<'a, SunsetRawMutex, N>, + ssh_chan_out: ChanOut<'a>, + counter: &'a CounterMutex, +} + +impl<'a, const N: usize> SftpOutputConsumer<'a, N> { + /// Run it to start the piping + pub async fn receive_task(&mut self) -> SftpResult<()> { + debug!("Running SftpOutout Consumer Reader task"); + let mut buf = [0u8; N]; + loop { + let rl = self.reader.read(&mut buf).await; + let mut _total = 0; + { + let mut lock = self.counter.lock().await; + *lock += rl; + _total = *lock; + } + + debug!("Output Consumer: ---> Reads {rl} bytes. Total {_total}"); + let mut scanning_buffer = &buf[..rl]; + if rl > 0 { + // Replaced write_all with loop to handle partial writes to discard issues in write_all + while scanning_buffer.len() > 0 { + trace!( + "Output Consumer: Tries to write {:?} bytes to ChanOut", + scanning_buffer.len() + ); + let wl = self.ssh_chan_out.write(scanning_buffer).await?; + debug!("Output Consumer: Written {:?} bytes ", wl); + if wl < scanning_buffer.len() { + debug!( + "Output Consumer: ChanOut accepted only part of the buffer" + ); + } + trace!( + "Output Consumer: Bytes written {:?}", + &scanning_buffer[..wl] + ); + scanning_buffer = &scanning_buffer[wl..]; + } + debug!("Output Consumer: Finished writing all bytes in read buffer"); + } else { + error!("Output Consumer: Empty array received"); + } + } + } +} + +/// Producer used to send data to a [`ChanOut`] without the restrictions +/// of mutable borrows +#[derive(Clone)] +pub struct SftpOutputProducer<'a, const N: usize> { + writer: PipeWriter<'a, SunsetRawMutex, N>, + counter: &'a CounterMutex, +} +impl<'a, const N: usize> SftpOutputProducer<'a, N> { + /// Sends the data encoded in the provided [`SftpSink`] without including + /// the size. + /// + /// Use this when you are sending chunks of data after a valid header + pub async fn send_data(&self, buf: &[u8]) -> SftpResult<()> { + Self::send_buffer(&self.writer, &buf, &self.counter).await; + Ok(()) + } + + /// Simplifies the task of sending a status response to the client. + pub async fn send_status( + &self, + req_id: ReqId, + status: StatusCode, + msg: &'static str, + ) -> SftpResult<()> { + let response = SftpPacket::Status( + req_id, + Status { code: status, message: msg.into(), lang: "en-US".into() }, + ); + trace!("Output Producer: Pushing a status message: {:?}", response); + self.send_packet(&response).await?; + Ok(()) + } + + /// Sends a SFTP Packet into the channel out, including the length field + pub async fn send_packet(&self, packet: &SftpPacket<'_>) -> SftpResult<()> { + let mut buf = [0u8; N]; + let mut sink = SftpSink::new(&mut buf); + packet.encode_response(&mut sink)?; + debug!("Output Producer: Sending packet {:?}", packet); + Self::send_buffer(&self.writer, &sink.used_slice(), &self.counter).await; + Ok(()) + } + + /// Internal associated method to log the writes to the pipe + async fn send_buffer( + writer: &PipeWriter<'a, SunsetRawMutex, N>, + buf: &[u8], + counter: &CounterMutex, + ) { + let mut _total = 0; + { + let mut lock = counter.lock().await; + *lock += buf.len(); + _total = *lock; + } + + debug!("Output Producer: <--- Sends {:?} bytes. Total {_total}", buf.len()); + trace!("Output Producer: Sending buffer {:?}", buf); + + // writer.write_all(buf); // ??? error[E0596]: cannot borrow `*writer` as mutable, as it is behind a `&` reference + + let mut buf = buf; + loop { + if buf.len() == 0 { + break; + } + + trace!("Output Producer: Tries to send {:?} bytes", buf.len()); + let bytes_sent = writer.write(&buf).await; + buf = &buf[bytes_sent..]; + trace!( + "Output Producer: sent {bytes_sent:?}. {:?} bytes remain ", + buf.len() + ); + } + } +} diff --git a/sftp/src/sftpserver.rs b/sftp/src/sftpserver.rs new file mode 100644 index 00000000..5942fa80 --- /dev/null +++ b/sftp/src/sftpserver.rs @@ -0,0 +1,663 @@ +use crate::error::{SftpError, SftpResult}; +use crate::proto::{ + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH, ENCODED_SSH_FXP_DATA_MIN_LENGTH, + MAX_NAME_ENTRY_SIZE, NameEntry, PFlags, SftpNum, +}; +use crate::server::SftpSink; +use crate::sftphandler::SftpOutputProducer; +use crate::{ + handles::OpaqueFileHandle, + proto::{Attrs, ReqId, StatusCode}, +}; + +use sunset::sshwire::SSHEncode; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// Result used to store the result of an Sftp Operation +pub type SftpOpResult = core::result::Result; + +/// To finish read requests the server needs to answer to +/// **subsequent READ requests** after all the data has been sent already +/// with a [`SftpPacket`] including a status code [`StatusCode::SSH_FX_EOF`]. +/// +/// [`ReadStatus`] enum has been implemented to keep record of these exhausted +/// read operations. +/// +/// See: +/// +/// - [Reading and Writing](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +/// - [Scanning Directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7) +#[derive(PartialEq, Debug, Default)] +pub enum ReadStatus { + /// There is more data to be read therefore the [`SftpServer`] will + /// send more data in the next read request. + #[default] + PendingData, + /// The server has provided all the data requested therefore the [`SftpServer`] + /// will send a [`SftpPacket`] including a status code [`StatusCode::SSH_FX_EOF`] + /// in the next read request. + EndOfFile, +} + +/// All trait functions are optional in the SFTP protocol. +/// Some less core operations have a Provided implementation returning +/// returns `SSH_FX_OP_UNSUPPORTED`. Common operations must be implemented, +/// but may return `Err(StatusCode::SSH_FX_OP_UNSUPPORTED)`. +pub trait SftpServer<'a, T> +where + T: OpaqueFileHandle, +{ + /// Opens a file for reading/writing + fn open( + &'_ mut self, + path: &str, + mode: &PFlags, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Open operation not defined: path = {:?}, attrs = {:?}", + path, + mode + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Close either a file or directory handle + fn close( + &mut self, + handle: &T, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Close operation not defined: handle = {:?}", + handle + ); + + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + /// Reads from a file that has previously being opened for reading + /// + /// ## Notes to the implementer: + /// + /// The implementer is expected to use the parameter `reply` [`DirReply`] to: + /// + /// - In case of no more data is to be sent, call `reply.send_eof()` + /// - There is more data to be sent from an open file: + /// 1. Call `reply.send_header()` with the length of data to be sent + /// 2. Call `reply.send_data()` once or multiple times to send all the data announced + /// 3. Do not call `reply.send_eof()` during this [`readdir`] method call + /// + + /// If the length communicated in the header does not match the total length of the data + /// sent using `reply.send_data()`, the SFTP session will be broken. + /// + #[allow(unused)] + fn read( + &mut self, + opaque_file_handle: &T, + offset: u64, + len: u32, + reply: &mut ReadReply<'_, N>, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Read operation not defined: handle = {:?}, offset = {:?}, len = {:?}", + opaque_file_handle, + offset, + len + ); + Err(SftpError::FileServerError(StatusCode::SSH_FX_OP_UNSUPPORTED)) + } + } + /// Writes to a file that has previously being opened for writing + fn write( + &mut self, + opaque_file_handle: &T, + offset: u64, + buf: &[u8], + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Write operation not defined: handle = {:?}, offset = {:?}, buf = {:?}", + opaque_file_handle, + offset, + buf + ); + Ok(()) + } + } + + /// Opens a directory and returns a handle + fn opendir( + &mut self, + dir: &str, + ) -> impl core::future::Future> { + async move { + log::error!("SftpServer OpenDir operation not defined: dir = {:?}", dir); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Reads the list of items in a directory and returns them using the [`DirReply`] + /// parameter. + /// + /// ## Notes to the implementer: + /// + /// The implementer is expected to use the parameter `reply` [`DirReply`] to: + /// + /// - In case of no more items in the directory to send, call `reply.send_eof()` + /// - There are more items in the directory: + /// 1. Call `reply.send_header()` with the number of items and the [`SSHEncode`] + /// length of all the items to be sent + /// 2. Call `reply.send_item()` for each of the items announced to be sent + /// 3. Do not call `reply.send_eof()` during this [`readdir`] method call + /// + /// If the length communicated in the header does not match the total length of all + /// the items sent using `reply.send_item()`, the SFTP session will be + /// broken. + /// + /// The server is expected to keep track of the number of items that remain to be sent + /// to the client since the client will only stop asking for more elements in the + /// directory when a read dir request is answer with an reply.send_eof() + /// + #[allow(unused_variables)] + fn readdir( + &mut self, + opaque_dir_handle: &T, + reply: &mut DirReply<'_, N>, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer ReadDir operation not defined: handle = {:?}", + opaque_dir_handle + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Provides the real path of the directory specified + fn realpath( + &mut self, + dir: &str, + ) -> impl core::future::Future>> { + async move { + log::error!( + "SftpServer RealPath operation not defined: dir = {:?}", + dir + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Provides the attributes of the given file path + fn attrs( + &mut self, + follow_links: bool, + file_path: &str, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Stats operation not defined: follow_link = {:?}, \ + file_path = {:?}", + follow_links, + file_path + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } +} + +/// A reference structure passed to the [`SftpServer::read()`] method to +/// allow replying with the read data. +/// Uses for [`ReadReply`] to: +/// +/// - In case of no more data avaliable to be sent, call `reply.send_eof()` +/// - There is data to be sent from an open file: +/// 1. Call `reply.send_header()` with the length of data to be sent +/// 2. Call `reply.send_data()` as many times as needed to complete a +/// sent of data of the announced length +/// 3. Do not call `reply.send_eof()` during this [`read`] method call +/// +/// It handles immutable sending data via the underlying sftp-channel +/// [`sunset_async::async_channel::ChanOut`] used in the context of an +/// SFTP Session. +/// +pub struct ReadReply<'g, const N: usize> { + /// The request Id that will be use`d in the response + req_id: ReqId, + + /// Immutable writer + chan_out: &'g SftpOutputProducer<'g, N>, + /// Length of data to be sent as announced in [`ReadReply::send_header`] + data_len: u32, + /// Length of data sent so far using [`ReadReply::send_data`] + data_sent_len: u32, +} + +impl<'g, const N: usize> ReadReply<'g, N> { + /// New instances can only be created within the crate. Users can only + /// use other public methods to use it. + pub(crate) fn new( + req_id: ReqId, + chan_out: &'g SftpOutputProducer<'g, N>, + ) -> Self { + ReadReply { req_id, chan_out, data_len: 0, data_sent_len: 0 } + } + + // TODO Make this enforceable + // TODO Automate encoding the SftpPacket + /// Sends a header for `SSH_FXP_DATA` response. This includes the total + /// response length, the packet type, request id and data length + /// + /// The packet data content, excluding the length must be sent using + /// [`ReadReply::send_data`] + pub async fn send_header(&mut self, data_len: u32) -> SftpResult<()> { + debug!( + "ReadReply: Sending header for request id {:?}: data length = {:?}", + self.req_id, data_len + ); + let mut s = [0u8; N]; + let mut sink = SftpSink::new(&mut s); + + let payload = + ReadReply::::encode_data_header(&mut sink, self.req_id, data_len)?; + + debug!( + "Sending header: len = {:?}, content = {:?}", + payload.len(), + payload + ); + // Sending payload_slice since we are not making use of the sink sftpPacket length calculation + self.chan_out.send_data(payload).await?; + self.data_len = data_len; + Ok(()) + } + + /// Sends a buffer with data. Call it as many times as needed to send + /// the announced data length + /// + /// **Important**: Call this after you have called `send_header` + pub async fn send_data(&mut self, buff: &[u8]) -> SftpResult<()> { + self.chan_out.send_data(buff).await?; + self.data_sent_len += buff.len() as u32; + Ok(()) + } + + /// Sends EOF meaning that there is no more data to be sent + /// + pub async fn send_eof(&self) -> SftpResult<()> { + self.chan_out.send_status(self.req_id, StatusCode::SSH_FX_EOF, "").await + } + + /// Indicates whether all the data announced in the header has been sent + /// + /// returns 0 when all data has been sent + /// returns >0 when there is still data to be sent + /// returns <0 when too much data has been sent + pub fn read_diff(&self) -> i32 { + (self.data_len as i32) - (self.data_sent_len as i32) + } + + fn encode_data_header( + sink: &'g mut SftpSink<'g>, + req_id: ReqId, + data_len: u32, + ) -> Result<&'g [u8], SftpError> { + // length field + (data_len + ENCODED_SSH_FXP_DATA_MIN_LENGTH).enc(sink)?; + // packet type (1) + u8::from(SftpNum::SSH_FXP_DATA).enc(sink)?; + // request id (4) + req_id.enc(sink)?; + // data length (4) + data_len.enc(sink)?; + Ok(sink.payload_slice()) + } +} + +#[cfg(test)] +mod read_reply_tests { + use super::*; + + #[cfg(test)] + extern crate std; + // #[cfg(test)] + // use std::println; + + #[test] + fn compose_header() { + const N: usize = 512; + + let req_id = ReqId(42); + let data_len = 128; + let mut buffer = [0u8; N]; + let mut sink = SftpSink::new(&mut buffer); + + let payload = + ReadReply::::encode_data_header(&mut sink, req_id, data_len).unwrap(); + + assert_eq!( + data_len + ENCODED_SSH_FXP_DATA_MIN_LENGTH, + u32::from_be_bytes(payload[..4].try_into().unwrap()) + ); + } +} + +/// Uses for [`DirReply`] to: +/// +/// - In case of no more items in the directory to be sent, call `reply.send_eof()` +/// - There are more items in the directory to be sent: +/// 1. Call `reply.send_header()` with the number of items and the [`SSHEncode`] +/// length of all the items to be sent +/// 2. Call `reply.send_item()` for each of the items announced to be sent +/// 3. Do not call `reply.send_eof()` during this [`readdir`] method call +/// +/// It handles immutable sending data via the underlying sftp-channel +/// [`sunset_async::async_channel::ChanOut`] used in the context of an +/// SFTP Session. +/// +pub struct DirReply<'g, const N: usize> { + /// The request Id that will be use`d in the response + req_id: ReqId, + /// Immutable writer + chan_out: &'g SftpOutputProducer<'g, N>, + /// Length of data to be sent as announced in [`ReadReply::send_header`] + data_len: u32, + /// Length of data sent so far using [`ReadReply::send_data`] + data_sent_len: u32, +} + +impl<'g, const N: usize> DirReply<'g, N> { + // const ENCODED_NAME_SFTP_PACKET_LENGTH: u32 = 9; + + /// New instances can only be created within the crate. Users can only + /// use other public methods to use it. + pub(crate) fn new( + req_id: ReqId, + chan_out: &'g SftpOutputProducer<'g, N>, + ) -> Self { + // DirReply { chan_out: chan_out_wrapper, req_id } + DirReply { req_id, chan_out, data_len: 0, data_sent_len: 0 } + } + + // TODO Make this enforceable + // TODO Automate encoding the SftpPacket + /// Sends the header to the client with the number of files as [`NameEntry`] and the [`SSHEncode`] + /// length of all these [`NameEntry`] items + pub async fn send_header( + &mut self, + count: u32, + items_encoded_len: u32, + ) -> SftpResult<()> { + debug!( + "I will send the header here for request id {:?}: count = {:?}, length = {:?}", + self.req_id, count, items_encoded_len + ); + let mut s = [0u8; N]; + let mut sink = SftpSink::new(&mut s); + + let payload = DirReply::::encode_data_header( + &mut sink, + self.req_id, + items_encoded_len, + count, + )?; + + debug!( + "Sending header: len = {:?}, content = {:?}", + payload.len(), + payload + ); + self.chan_out.send_data(payload).await?; + self.data_len = items_encoded_len; + Ok(()) + } + + /// Sends a directory item to the client as a [`NameEntry`] + /// + /// Call this + pub async fn send_item(&mut self, name_entry: &NameEntry<'_>) -> SftpResult<()> { + let mut buffer = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut sftp_sink = SftpSink::new(&mut buffer); + name_entry.enc(&mut sftp_sink).map_err(|err| { + error!("WireError: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + + self.chan_out.send_data(sftp_sink.payload_slice()).await?; + self.data_sent_len += sftp_sink.payload_len() as u32; + Ok(()) + } + + /// Sends EOF meaning that there is no more files in the directory + pub async fn send_eof(&self) -> SftpResult<()> { + self.chan_out.send_status(self.req_id, StatusCode::SSH_FX_EOF, "").await + } + + /// Indicates whether all the data announced in the header has been sent + /// + /// returns 0 when all data has been sent + /// returns >0 when there is still data to be sent + /// returns <0 when too much data has been sent + pub fn read_diff(&self) -> i32 { + (self.data_len as i32) - (self.data_sent_len as i32) + } + + fn encode_data_header( + sink: &'g mut SftpSink<'g>, + req_id: ReqId, + items_encoded_len: u32, + count: u32, + ) -> Result<&'g [u8], SftpError> { + // We need to consider the packet type, Id and count fields + // This way I collect data required for the header and collect + // valid entries into a vector (only std) + (items_encoded_len + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH).enc(sink)?; + u8::from(SftpNum::SSH_FXP_NAME).enc(sink)?; + req_id.enc(sink)?; + count.enc(sink)?; + + Ok(sink.payload_slice()) + } +} + +#[cfg(test)] +mod dir_reply_tests { + use super::*; + + #[cfg(test)] + extern crate std; + // #[cfg(test)] + // use std::println; + + #[test] + fn compose_header() { + const N: usize = 512; + + let req_id = ReqId(42); + let data_len = 128; + let count = 128; + let mut buffer = [0u8; N]; + let mut sink = SftpSink::new(&mut buffer); + + let payload = + DirReply::::encode_data_header(&mut sink, req_id, data_len, count) + .unwrap(); + + // println!("{payload:?}"); + + // println!("{:?}", &u32::from_be_bytes(payload[..4].try_into().unwrap())); + assert_eq!( + data_len + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH, + u32::from_be_bytes(payload[..4].try_into().unwrap()) + ); + } +} + +pub mod helpers { + use crate::{ + error::SftpResult, + proto::{MAX_NAME_ENTRY_SIZE, NameEntry}, + server::SftpSink, + }; + + use sunset::sshwire::SSHEncode; + + /// Helper function to get the length of a given [`NameEntry`] + /// as it would be serialized to the wire. + /// + /// Use this function to calculate the total length of a collection + /// of `NameEntry`s in order to send a correct response Name header + pub fn get_name_entry_len(name_entry: &NameEntry<'_>) -> SftpResult { + let mut buf = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut temp_sink = SftpSink::new(&mut buf); + name_entry.enc(&mut temp_sink)?; + Ok(temp_sink.payload_len() as u32) + } +} + +#[cfg(feature = "std")] +use crate::proto::Filename; +#[cfg(feature = "std")] +use std::{ + fs::{DirEntry, Metadata, ReadDir}, + os::{linux::fs::MetadataExt, unix::fs::PermissionsExt}, + time::SystemTime, +}; + +#[cfg(feature = "std")] +/// This is a helper structure to make ReadDir into something manageable for +/// [`DirReply`] +#[derive(Debug)] +pub struct DirEntriesCollection { + /// Number of elements + count: u32, + /// Computed length of all the encoded elements + encoded_length: u32, + /// The actual entries. As you can see these are DirEntry. This is a std choice + entries: Vec, +} + +#[cfg(feature = "std")] +impl DirEntriesCollection { + /// Creates this DirEntriesCollection so linux std users do not need to + /// translate `std` directory elements into Sftp structures before sending a response + /// back to the client + pub fn new(dir_iterator: ReadDir) -> SftpOpResult { + use log::info; + + let mut encoded_length = 0; + + let entries: Vec = dir_iterator + .filter_map(|entry_result| { + let entry = entry_result.ok()?; + let filename = entry.file_name().to_string_lossy().into_owned(); + let name_entry = NameEntry { + filename: Filename::from(filename.as_str()), + _longname: Filename::from(""), + attrs: Self::get_attrs_or_empty(entry.metadata()), + }; + + let mut buffer = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut sftp_sink = SftpSink::new(&mut buffer); + name_entry.enc(&mut sftp_sink).ok()?; + encoded_length += u32::try_from(sftp_sink.payload_len()) + .map_err(|_| StatusCode::SSH_FX_FAILURE) + .ok()?; + Some(entry) + }) + .collect(); + + let count = + u32::try_from(entries.len()).map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + info!( + "Processed {} entries, estimated serialized length: {}", + count, encoded_length + ); + + Ok(Self { count, encoded_length, entries }) + } + + /// Using the provided [`DirReply`] sends a response taking care of + /// composing a SFTP Entry header and sending everything in the right order + /// + /// Returns a [`ReadStatus`] + pub async fn send_response( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult { + self.send_entries_header(reply).await?; + self.send_entries(reply).await?; + Ok(ReadStatus::EndOfFile) + } + /// Sends a header for all the elements in the ReadDir iterator + /// + /// It will take care of counting them and finding the serialized length of each + /// element + async fn send_entries_header( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + reply.send_header(self.count, self.encoded_length).await.map_err(|e| { + debug!("Could not send header {e:?}"); + StatusCode::SSH_FX_FAILURE + }) + } + + /// Sends the entries in the ReadDir iterator back to the client + async fn send_entries( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + for entry in &self.entries { + let filename = entry.file_name().to_string_lossy().into_owned(); + let attrs = Self::get_attrs_or_empty(entry.metadata()); + let name_entry = NameEntry { + filename: Filename::from(filename.as_str()), + _longname: Filename::from(""), + attrs, + }; + debug!("Sending new item: {:?}", name_entry); + reply.send_item(&name_entry).await.map_err(|err| { + error!("SftpError: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + } + Ok(()) + } + + fn get_attrs_or_empty( + maybe_metadata: Result, + ) -> Attrs { + maybe_metadata.map(get_file_attrs).unwrap_or_default() + } +} + +#[cfg(feature = "std")] +/// [`std`] helper function to get [`Attrs`] from a [`Metadata`]. +pub fn get_file_attrs(metadata: Metadata) -> Attrs { + let time_to_u32 = |time_result: std::io::Result| { + time_result + .ok()? + .duration_since(SystemTime::UNIX_EPOCH) + .ok()? + .as_secs() + .try_into() + .ok() + }; + + Attrs { + size: Some(metadata.len()), + uid: Some(metadata.st_uid()), + gid: Some(metadata.st_gid()), + permissions: Some(metadata.permissions().mode()), + atime: time_to_u32(metadata.accessed()), + mtime: time_to_u32(metadata.modified()), + ext_count: None, + } +} diff --git a/sftp/src/sftpsink.rs b/sftp/src/sftpsink.rs new file mode 100644 index 00000000..31fb0c76 --- /dev/null +++ b/sftp/src/sftpsink.rs @@ -0,0 +1,99 @@ +use crate::proto::SFTP_FIELD_LEN_LENGTH; + +use sunset::sshwire::{SSHSink, WireError}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// A implementation fo [`SSHSink`] that observes some constraints for +/// SFTP packets +/// +/// **Important**: It needs to be [`SftpSink::finalize`] to add the packet +/// len +#[derive(Default)] +pub struct SftpSink<'g> { + buffer: &'g mut [u8], + index: usize, +} + +impl<'g> SftpSink<'g> { + /// Initializes the Sink, with the particularity that it will leave + /// [`crate::proto::SFTP_FIELD_LEN_LENGTH`] bytes empty at the + /// start of the buffer that will contain the total packet length + /// once the [`SftpSink::finalize`] method is called + pub fn new(s: &'g mut [u8]) -> Self { + SftpSink { buffer: s, index: SFTP_FIELD_LEN_LENGTH } + } + + /// Finalise the buffer by prepending the packet length field, + /// excluding the field itself. + /// + /// **Returns** the final index in the buffer as a reference of the + /// space used + fn finalize(&mut self) -> usize { + if self.index <= SFTP_FIELD_LEN_LENGTH { + warn!("SftpSink trying to terminate it before pushing data"); + return 0; + } // size is 0 + let used_size = self.payload_len() as u32; + + used_size + .to_be_bytes() + .iter() + .enumerate() + .for_each(|(i, v)| self.buffer[i] = *v); + + self.index + } + + /// Auxiliary method to allow seen the len used by the encoded payload + pub fn payload_len(&self) -> usize { + self.index - SFTP_FIELD_LEN_LENGTH + } + + /// Auxiliary method to allow an immutable reference to the encoded payload + /// excluding the `u32` length field prepended to it + pub fn payload_slice(&self) -> &[u8] { + &self.buffer + [SFTP_FIELD_LEN_LENGTH..SFTP_FIELD_LEN_LENGTH + self.payload_len()] + } + + /// Auxiliary method to allow an immutable reference to the full used + /// data (includes the prepended length field) + /// + /// **Important:** Call this after [`SftpSink::finalize()`] + pub fn used_slice(&self) -> &[u8] { + debug!( + "SftpSink used_slice called, total len: {}. Index: {}", + SFTP_FIELD_LEN_LENGTH + self.payload_len(), + self.index + ); + &self.buffer[..SFTP_FIELD_LEN_LENGTH + self.payload_len()] + } + + /// Reset the index and cleans the length field + pub fn reset(&mut self) -> () { + debug!("SftpSink reset called when index was {:?}", self.index); + self.index = SFTP_FIELD_LEN_LENGTH; + for i in 0..SFTP_FIELD_LEN_LENGTH { + self.buffer[i] = 0; + } + } +} + +impl<'g> SSHSink for SftpSink<'g> { + fn push(&mut self, v: &[u8]) -> sunset::sshwire::WireResult<()> { + if v.len() + self.index > self.buffer.len() { + return Err(WireError::NoRoom); + } + trace!("Sink index: {:}", self.index); + v.iter().for_each(|val| { + trace!("Writing val {:} at index {:}", *val, self.index); + self.buffer[self.index] = *val; + self.index += 1; + }); + trace!("Sink new index: {:}", self.index); + self.finalize(); + Ok(()) + } +} diff --git a/sftp/src/sftpsource.rs b/sftp/src/sftpsource.rs new file mode 100644 index 00000000..69c9699e --- /dev/null +++ b/sftp/src/sftpsource.rs @@ -0,0 +1,237 @@ +use crate::proto::{ + SFTP_FIELD_ID_INDEX, SFTP_FIELD_LEN_INDEX, SFTP_FIELD_LEN_LENGTH, + SFTP_FIELD_REQ_ID_INDEX, SFTP_FIELD_REQ_ID_LEN, SftpNum, +}; + +use sunset::sshwire::{SSHSource, WireError, WireResult}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// SftpSource implements [`SSHSource`] and also extra functions to handle +/// some challenges related to long SFTP packets in constrained environments +#[derive(Default, Debug)] +pub struct SftpSource<'de> { + buffer: &'de [u8], + index: usize, +} + +impl<'de> SSHSource<'de> for SftpSource<'de> { + fn take(&mut self, len: usize) -> sunset::sshwire::WireResult<&'de [u8]> { + if len + self.index > self.buffer.len() { + return Err(WireError::RanOut); + } + let original_index = self.index; + let slice = &self.buffer[self.index..self.index + len]; + self.index += len; + trace!( + "slice returned: {:?}. original index {:?}, new index: {:?}", + slice, original_index, self.index + ); + Ok(slice) + } + + fn remaining(&self) -> usize { + self.buffer.len() - self.index + } + + fn ctx(&mut self) -> &mut sunset::packets::ParseContext { + todo!("Which context for sftp?"); + } +} + +impl<'de> SftpSource<'de> { + /// Creates a new [`SftpSource`] referencing a buffer + pub fn new(buffer: &'de [u8]) -> Self { + debug!("New source with content: : {:?}", buffer); + SftpSource { buffer: buffer, index: 0 } + } + /// Peeks the buffer for packet type [`SftpNum`]. This does not advance + /// the reading index + /// + /// Useful to observe the packet fields in special conditions where a + /// `dec(s)` would fail + /// + /// **Warning**: will only work in well formed packets, in other case + /// the result will contains garbage + pub(crate) fn peek_packet_type(&self) -> WireResult { + if self.buffer.len() <= SFTP_FIELD_ID_INDEX { + debug!( + "Peak packet type failed: buffer len <= SFTP_FIELD_ID_INDEX ( {:?} <= {:?})", + self.buffer.len(), + SFTP_FIELD_ID_INDEX + ); + Err(WireError::RanOut) + } else { + Ok(SftpNum::from(self.buffer[SFTP_FIELD_ID_INDEX])) + } + } + + /// Peaks the buffer for packet length field. This does not advance the reading index + /// + /// Useful to observe the packet fields in special conditions where a `dec(s)` + /// would fail + /// + /// Use `peak_total_packet_len` instead if you want to also consider the the + /// length field + /// + /// **Warning**: will only work in well formed packets, in other case the result + /// will contains garbage + pub(crate) fn peak_packet_len(&self) -> WireResult { + if self.buffer.len() < SFTP_FIELD_LEN_INDEX + SFTP_FIELD_LEN_LENGTH { + Err(WireError::RanOut) + } else { + let bytes: [u8; 4] = self.buffer + [SFTP_FIELD_LEN_INDEX..SFTP_FIELD_LEN_INDEX + SFTP_FIELD_LEN_LENGTH] + .try_into() + .expect("slice length mismatch"); + + Ok(u32::from_be_bytes(bytes)) + } + } + + /// Peaks the packet in the source to obtain a total packet length, which + /// considers the length of the length field itself. For the packet length field + /// use [`peak_packet_len()`] + /// + /// This does not advance the reading index + /// + /// + /// **Warning**: will only work in well formed packets, in other case the result + /// will contains garbage + pub(crate) fn peak_total_packet_len(&self) -> WireResult { + Ok(self.peak_packet_len()? + SFTP_FIELD_LEN_LENGTH as u32) + } + + /// Compares the total source capacity and the peaked packet length + /// plus the length field length itself to find out if the packet fit + /// in the source + /// **Warning**: will only work in well formed packets, in other case + /// the result will contains garbage + pub fn packet_fits(&self) -> bool { + match self.peak_total_packet_len() { + Ok(len) => self.buffer.len() >= len as usize, + Err(_) => false, + } + } + + /// Peaks the buffer for packet request id [`u32`]. This does not advance + /// the reading index + /// + /// Useful to observe the packet fields in special conditions where a + /// `dec(s)` would fail + /// + /// **Warning**: will only work in well formed packets, in other case + /// the result will contains garbage + pub fn peak_packet_req_id(&self) -> WireResult { + if self.buffer.len() < SFTP_FIELD_REQ_ID_INDEX + SFTP_FIELD_REQ_ID_LEN { + Err(WireError::RanOut) + } else { + let bytes: [u8; 4] = self.buffer[SFTP_FIELD_REQ_ID_INDEX + ..SFTP_FIELD_REQ_ID_INDEX + SFTP_FIELD_LEN_LENGTH] + .try_into() + .expect("slice length mismatch"); + + Ok(u32::from_be_bytes(bytes)) + } + } + /// Returns a slice on the used portion of the held buffer. + /// + /// This does not modify the internal index + pub fn buffer_used(&self) -> &[u8] { + &self.buffer[..self.index] + } + + /// returns a slice on the held buffer and makes it unavailable for further + /// decodes. + pub fn consume_all(&mut self) -> &[u8] { + self.index = self.buffer.len(); + self.buffer + } +} + +#[cfg(test)] +mod local_tests { + use super::*; + + fn status_buffer() -> [u8; 27] { + let expected_status_packet_slice: [u8; 27] = [ + 0, 0, 0, 23, // Packet len + 101, // Packet type + 0, 0, 0, 16, // ReqId + 0, 0, 0, 1, // Status code: SSH_FX_EOF + 0, 0, 0, 1, // string message length + 65, // string message content + 0, 0, 0, 5, // string lang length + 101, 110, 45, 85, 83, // string lang content + ]; + expected_status_packet_slice + } + + #[test] + fn peaking_len() { + let buffer_status = status_buffer(); + let source = SftpSource::new(&buffer_status); + + let read_packet_len = source.peak_packet_len().unwrap(); + let original_packet_len = 23u32; + assert_eq!(original_packet_len, read_packet_len); + } + #[test] + fn peaking_total_len() { + let buffer_status = status_buffer(); + let source = SftpSource::new(&buffer_status); + + let read_total_packet_len = source.peak_total_packet_len().unwrap(); + let original_total_packet_len = 23u32 + 4u32; + assert_eq!(original_total_packet_len, read_total_packet_len); + } + + #[test] + fn peaking_type() { + let buffer_status = status_buffer(); + let source = SftpSource::new(&buffer_status); + let read_packet_type = source.peek_packet_type().unwrap(); + let original_packet_type = SftpNum::from(101u8); + assert_eq!(original_packet_type, read_packet_type); + } + #[test] + fn peaking_req_id() { + let buffer_status = status_buffer(); + let source = SftpSource::new(&buffer_status); + let read_req_id = source.peak_packet_req_id().unwrap(); + let original_req_id = 16u32; + assert_eq!(original_req_id, read_req_id); + } + + #[test] + fn packet_does_fit() { + let buffer_status = status_buffer(); + let source = SftpSource::new(&buffer_status); + assert_eq!(true, source.packet_fits()); + } + + #[test] + fn packet_does_not_fit() { + let buffer_status = status_buffer(); + let no_room_buffer = &buffer_status[..buffer_status.len() - 2]; + let source = SftpSource::new(no_room_buffer); + assert_eq!(false, source.packet_fits()); + } + + #[test] + fn consume_all_remaining() { + let inc_array: [u8; 512] = core::array::from_fn(|i| (i % 255) as u8); + let mut source = SftpSource::new(&inc_array); + let _consumed = source.consume_all(); + assert_eq!(0usize, source.remaining()); + } + + #[test] + fn consume_all_consumed() { + let inc_array: [u8; 512] = core::array::from_fn(|i| (i % 255) as u8); + let mut source = SftpSource::new(&inc_array); + let consumed = source.consume_all(); + assert_eq!(inc_array.len(), consumed.len()); + } +} diff --git a/src/channel.rs b/src/channel.rs index 10c66142..dd80814d 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -211,7 +211,14 @@ impl Channels { let ch = self.get_mut(num)?; ch.finished_input(len); if let Some(w) = ch.check_window_adjust()? { - s.send(w)?; + match s.send(w) { + Ok(_) => ch.pending_adjust = 0, + Err(Error::NoRoom { .. }) => { + // TODO better retry rather than hoping a retry occurs + debug!("noroom for adjustment") + } + error => return error, + } } Ok(()) } @@ -1028,11 +1035,12 @@ impl Channel { } /// Returns a window adjustment packet if required + /// + /// Does not reset the adjustment to 0, should be done by caller on successful send. fn check_window_adjust(&mut self) -> Result>> { let num = self.send.as_mut().trap()?.num; if self.pending_adjust > self.full_window / 2 { let adjust = self.pending_adjust as u32; - self.pending_adjust = 0; let p = packets::ChannelWindowAdjust { num, adjust }.into(); Ok(Some(p)) } else { diff --git a/src/encrypt.rs b/src/encrypt.rs index 34c71ca7..bc520c87 100644 --- a/src/encrypt.rs +++ b/src/encrypt.rs @@ -130,7 +130,9 @@ impl KeyState { buf: &mut [u8], ) -> Result { let e = self.enc.encrypt(payload_len, buf, self.seq_encrypt.0); - self.seq_encrypt += 1; + if !matches!(e, Err(Error::NoRoom { .. })) { + self.seq_encrypt += 1; + } e } diff --git a/src/packets.rs b/src/packets.rs index 6ffe97d2..a96c9a93 100644 --- a/src/packets.rs +++ b/src/packets.rs @@ -841,7 +841,7 @@ pub struct DirectTcpip<'a> { pub struct Unknown<'a>(pub &'a [u8]); impl<'a> Unknown<'a> { - fn new(u: &'a [u8]) -> Self { + pub fn new(u: &'a [u8]) -> Self { let u = Unknown(u); trace!("saw unknown variant \"{u}\""); u @@ -882,7 +882,7 @@ pub struct ParseContext { // Set to true if an unknown variant is encountered. // Packet length checks should be omitted in that case. - pub(crate) seen_unknown: bool, + pub seen_unknown: bool, } impl ParseContext { diff --git a/testing/ci.sh b/testing/ci.sh index 162df28a..4b01fe79 100755 --- a/testing/ci.sh +++ b/testing/ci.sh @@ -74,6 +74,12 @@ cargo build --release --no-default-features --features w5500,romfw ) size target/thumbv6m-none-eabi/release/sunset-demo-picow | tee "$OUT/picow-size.txt" +( +cd demo/sftp/std +cargo build +cargo test +) + ( cd fuzz cargo check --features nofuzz --profile fuzz