diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 581b1b2a..b5be8aeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: all: strategy: matrix: - # 1.83 is an arbitrary minimum, tested to notice when it bumps - rust_version: [stable, nightly, 1.83] + # 1.87 is an arbitrary minimum, tested to notice when it bumps + rust_version: [stable, nightly, 1.87] runs-on: ubuntu-latest env: RUSTUP_TOOLCHAIN: ${{ matrix.rust_version }} diff --git a/Cargo.lock b/Cargo.lock index 86712c2a..e9cad532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,9 +805,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" @@ -934,15 +934,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", ] @@ -1812,11 +1812,12 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ - "num_enum_derive 0.7.3", + "num_enum_derive 0.7.4", + "rustversion", ] [[package]] @@ -1832,9 +1833,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro2", "quote", @@ -2011,7 +2012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" dependencies = [ "arrayvec", - "num_enum 0.7.3", + "num_enum 0.7.4", "paste", ] @@ -2766,7 +2767,7 @@ name = "sunset-async" version = "0.3.0" dependencies = [ "embassy-futures", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embedded-io-async", "log", "portable-atomic", @@ -2783,7 +2784,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", @@ -2813,7 +2814,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", @@ -2838,6 +2839,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" @@ -2848,7 +2875,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", @@ -2876,6 +2903,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.4", + "paste", + "sunset", + "sunset-async", + "sunset-sshwire-derive", +] + [[package]] name = "sunset-sshwire-derive" version = "0.2.0" @@ -2891,7 +2933,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 e6a35697..c415ba01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,12 +7,14 @@ repository = "https://github.com/mkj/sunset" categories = ["network-programming", "embedded", "no-std"] license = "0BSD" keywords = ["ssh"] +rust-version = "1.87" [workspace] members = [ "demo/picow", "demo/std", "fuzz", "sftp", + "demo/sftp/std", "stdasync", # workspace.dependencies paths are automatic ] @@ -77,7 +79,6 @@ mlkem = ["dep:ml-kem"] openssh-key = ["ssh-key"] # implements embedded_io::Error for sunset::Error embedded-io = ["dep:embedded-io"] - # Arbitrary for fuzzing. std is required for derive(Arbitrary) arbitrary = ["dep:arbitrary", "std"] diff --git a/README.md b/README.md index 918beb09..9549ac76 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,20 @@ Working: - hmac-sha256 integrity - rsa (`std`-only unless someone writes a `no_std` crate) - `~.` client escape sequences +- Post quantum hybrid key exchange (mlkem) Desirable: - SFTP +- sntrup761 - TCP forwarding -- Post quantum hybrid key exchange - A std server example - Perhaps aes256-gcm - Perhaps ECDSA, hardware often supports it ahead of ed25519 ## Rust versions -At the time of writing Sunset will build with Rust 1.83. +At the time of writing Sunset will build with Rust 1.87. The requirement may increase whenever useful, targetting stable. ## Checks @@ -61,7 +62,7 @@ Release builds should not panic, instead returning `Error::bug()`. `debug_assert!` is used in some places for invariants during testing or fuzzing. -Some attempts are made to clear sensitive memory after use, but stack copies +Some attempts are made to clear sensitive memory after use, but compiler-generated copies will not be cleared. ## Author diff --git a/async/src/async_sunset.rs b/async/src/async_sunset.rs index cabba933..9e7bd6a4 100644 --- a/async/src/async_sunset.rs +++ b/async/src/async_sunset.rs @@ -214,7 +214,7 @@ impl<'a, CS: CliServ> AsyncSunset<'a, CS> { fn discard_channels(&self, inner: &mut Inner) -> Result<()> { if let Some((num, dt, _len)) = inner.runner.read_channel_ready() { - if !self.chan_readcount(num, dt).load(Acquire) > 0 { + if self.chan_readcount(num, dt).load(Acquire) == 0 { // There are no live ChanIn or ChanInOut for the num/dt, // so nothing will read the channel. // Discard the data so it doesn't block forever. diff --git a/async/src/client.rs b/async/src/client.rs index 87eb54ef..857a5bbe 100644 --- a/async/src/client.rs +++ b/async/src/client.rs @@ -48,7 +48,7 @@ impl<'a> SSHClient<'a> { ) -> Result> { match self.sunset.progress(ph).await? { Event::Cli(x) => Ok(x), - Event::None => return Ok(CliEvent::PollAgain), + Event::None => Ok(CliEvent::PollAgain), Event::Progressed => Ok(CliEvent::PollAgain), _ => Err(Error::bug()), } diff --git a/changelog.md b/changelog.md index c0c039dd..21e859ae 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,37 @@ # Sunset Changelog +# unreleased + +### Added + +- Add server authentication helpers `matches_username()`, + `matches_password()`. + +- Add environment session variable support + +- Add mlkem768x25519 hybrid post-quantum key exchange + Enabled by `mlkem` feature, will soon be default. + +### Fixed + +- Fix public key authentication. Github #30 + +- Don't fail in some circumstances during key exchange when + packets are received in particular order. Github #25, Github #27 + +- Fix a hang where channels wouldn't get woken for more output + after the SSH stream was written out. Github #25 + +- Fix using sshwire-derive outside of sunset + +- Fix winch signal for sunsetc (regression in 0.3.0) + +### Changed + +- Log a better warning when host key signatures fail + +- Improve exit code handling in sunsetc + ## 0.3.0 - 2025-06-16 ### Changed diff --git a/demo/common/src/config.rs b/demo/common/src/config.rs index 4e7cf8ef..bbcba331 100644 --- a/demo/common/src/config.rs +++ b/demo/common/src/config.rs @@ -99,7 +99,7 @@ impl SSHConfig { } pub fn set_console_pw(&mut self, pw: Option<&str>) -> Result<()> { - self.console_pw = pw.map(|p| PwHash::new(p)).transpose()?; + self.console_pw = pw.map(PwHash::new).transpose()?; Ok(()) } @@ -112,7 +112,7 @@ impl SSHConfig { } pub fn set_admin_pw(&mut self, pw: Option<&str>) -> Result<()> { - self.admin_pw = pw.map(|p| PwHash::new(p)).transpose()?; + self.admin_pw = pw.map(PwHash::new).transpose()?; Ok(()) } @@ -178,11 +178,11 @@ where let ad = Ipv4Address::from_bits(ad); let prefix = SSHDecode::dec(s)?; if prefix > 32 { - // emabassy panics, so test it here + // embassy panics, so test it here return Err(WireError::PacketWrong); } let gw: Option = dec_option(s)?; - let gateway = gw.map(|gw| Ipv4Address::from_bits(gw)); + let gateway = gw.map(Ipv4Address::from_bits); Ok(StaticConfigV4 { address: Ipv4Cidr::new(ad, prefix), gateway, @@ -308,14 +308,13 @@ impl PwHash { return false; } let prehash = Self::prehash(pw, &self.salt); - let check_hash = - bcrypt::bcrypt(self.cost as u32, self.salt.clone(), &prehash); + let check_hash = bcrypt::bcrypt(self.cost as u32, self.salt, &prehash); check_hash.ct_eq(&self.hash).into() } fn prehash(pw: &str, salt: &[u8]) -> [u8; 32] { // OK unwrap: can't fail, accepts any length - let mut prehash = Hmac::::new_from_slice(&salt).unwrap(); + let mut prehash = Hmac::::new_from_slice(salt).unwrap(); prehash.update(pw.as_bytes()); prehash.finalize().into_bytes().into() } diff --git a/demo/common/src/menu.rs b/demo/common/src/menu.rs index 8de176d1..ed617340 100644 --- a/demo/common/src/menu.rs +++ b/demo/common/src/menu.rs @@ -391,7 +391,7 @@ where Some(arg) => { match menu.items.iter().find(|i| i.command == arg) { Some(item) => { - self.print_long_help(&item); + self.print_long_help(item); } None => { writeln!( @@ -406,7 +406,7 @@ where _ => { writeln!(self.context, "AVAILABLE ITEMS:").unwrap(); for item in menu.items { - self.print_short_help(&item); + self.print_short_help(item); } if self.depth != 0 { self.print_short_help(&Item { diff --git a/demo/common/src/menu_buf.rs b/demo/common/src/menu_buf.rs index 2176c736..96297665 100644 --- a/demo/common/src/menu_buf.rs +++ b/demo/common/src/menu_buf.rs @@ -17,7 +17,7 @@ impl AsyncMenuBuf { W: embedded_io_async::Write, { let mut b = self.s.as_str().as_bytes(); - while b.len() > 0 { + while !b.is_empty() { let l = w.write(b).await?; b = &b[l..]; } diff --git a/demo/common/src/server.rs b/demo/common/src/server.rs index c0c79040..8ca98157 100644 --- a/demo/common/src/server.rs +++ b/demo/common/src/server.rs @@ -42,7 +42,7 @@ pub async fn listen( continue; } - let r = session(&mut socket, &config, demo).await; + let r = session(&mut socket, config, demo).await; if let Err(e) = r { warn!("Ended with error {e:#?}"); } @@ -118,6 +118,7 @@ impl DemoCommon { ServEvent::PubkeyAuth(a) => self.handle_pubkey(a), ServEvent::OpenSession(a) => self.open_session(a), ServEvent::SessionPty(a) => a.succeed(), + ServEvent::SessionEnv(a) => a.succeed(), ServEvent::SessionSubsystem(a) => { info!("Ignored request for subsystem '{}'", a.command()?); Ok(()) @@ -163,14 +164,15 @@ impl DemoCommon { a.reject() } - fn handle_firstauth(&self, a: ServFirstAuth) -> Result<()> { + fn handle_firstauth(&mut self, mut a: ServFirstAuth) -> Result<()> { let username = a.username()?; if !self.is_admin(username) && self.config.console_noauth { info!("Allowing auth for user {username}"); return a.allow(); }; - // a.pubkey().password() + // Explicitly enable password authentication + a.enable_password_auth(true)?; Ok(()) } diff --git a/demo/picow/src/flashconfig.rs b/demo/picow/src/flashconfig.rs index fde94590..ebf93b31 100644 --- a/demo/picow/src/flashconfig.rs +++ b/demo/picow/src/flashconfig.rs @@ -48,8 +48,10 @@ struct FlashConfig<'a> { impl FlashConfig<'_> { const BUF_SIZE: usize = 4 + SSHConfig::BUF_SIZE + 32; } -const _: () = - assert!(FlashConfig::BUF_SIZE % 4 == 0, "flash reads must be a multiple of 4"); +const _: () = assert!( + FlashConfig::BUF_SIZE.is_multiple_of(4), + "flash reads must be a multiple of 4" +); fn config_hash(config: &SSHConfig) -> Result<[u8; 32]> { let mut h = sha2::Sha256::new(); @@ -113,8 +115,8 @@ pub async fn load(fl: &mut Fl<'_>) -> Result { pub async fn save(fl: &mut Fl<'_>, config: &SSHConfig) -> Result<()> { let sc = FlashConfig { version: SSHConfig::CURRENT_VERSION, - config: OwnOrBorrow::Borrow(&config), - hash: config_hash(&config)?, + config: OwnOrBorrow::Borrow(config), + hash: config_hash(config)?, }; let l = sshwire::write_ssh(&mut fl.buf, &sc)?; let buf = &fl.buf[..l]; @@ -133,7 +135,7 @@ pub async fn save(fl: &mut Fl<'_>, config: &SSHConfig) -> Result<()> { trace!("flash write"); fl.flash - .write(CONFIG_OFFSET, &buf) + .write(CONFIG_OFFSET, buf) .await .map_err(|_| Error::msg("flash write error"))?; diff --git a/demo/picow/src/picowmenu.rs b/demo/picow/src/picowmenu.rs index a9cbddc5..7d2b5ad0 100644 --- a/demo/picow/src/picowmenu.rs +++ b/demo/picow/src/picowmenu.rs @@ -511,7 +511,7 @@ const SERIAL_ITEM: Item = Item { }; fn enter_auth(context: &mut MenuCtx) { - let _ = writeln!(context, "In auth menu").unwrap(); + writeln!(context, "In auth menu").unwrap(); } fn endis(v: bool) -> &'static str { @@ -571,7 +571,7 @@ fn do_clear_key(_item: &Item, args: &[&str], context: &mut MenuCtx) { fn do_console_pw(_item: &Item, args: &[&str], context: &mut MenuCtx) { let pw = args[0]; - if pw.as_bytes().len() > MAX_PW_LEN { + if pw.len() > MAX_PW_LEN { let _ = writeln!(context, "Too long"); return; } @@ -612,7 +612,7 @@ fn do_console_clear_pw(_item: &Item, args: &[&str], context: &mut MenuC fn do_admin_pw(_item: &Item, args: &[&str], context: &mut MenuCtx) { let pw = args[0]; - if pw.as_bytes().len() > MAX_PW_LEN { + if pw.len() > MAX_PW_LEN { let _ = writeln!(context, "Too long"); return; } diff --git a/demo/picow/src/usb.rs b/demo/picow/src/usb.rs index f64e7248..724738eb 100644 --- a/demo/picow/src/usb.rs +++ b/demo/picow/src/usb.rs @@ -84,10 +84,10 @@ pub(crate) async fn task( let usb_fut = usb.run(); // console via SSH on if00 - let io0_run = console_if00_run(&global, cdc0); + let io0_run = console_if00_run(global, cdc0); // Admin menu on if02 - let io2_run = menu_if02_run(&global, cdc2); + let io2_run = menu_if02_run(global, cdc2); // keyboard // let hid_run = keyboard::run(&global, hid); @@ -192,9 +192,9 @@ impl<'a, D: Driver<'a>> Read for CDCRead<'a, '_, D> { let b = self.fill_buf().await?; let n = ret.len().min(b.len()); - (&mut ret[..n]).copy_from_slice(&b[..n]); + ret[..n].copy_from_slice(&b[..n]); self.consume(n); - return Ok(n); + Ok(n) } } @@ -214,7 +214,7 @@ impl<'a, D: Driver<'a>> BufRead for CDCRead<'a, '_, D> { } debug_assert!(self.end > 0); - return Ok(&self.buf[self.start..self.end]); + Ok(&self.buf[self.start..self.end]) } fn consume(&mut self, amt: usize) { diff --git a/demo/picow/src/wifi.rs b/demo/picow/src/wifi.rs index d961611a..4968a52b 100644 --- a/demo/picow/src/wifi.rs +++ b/demo/picow/src/wifi.rs @@ -64,7 +64,7 @@ pub(crate) async fn wifi_stack( ); static STATE: StaticCell = StaticCell::new(); - let state = STATE.init_with(|| cyw43::State::new()); + let state = STATE.init_with(cyw43::State::new); let (net_device, control, runner) = cyw43::new(state, pwr, spi, fw).await; spawner.spawn(wifi_task(runner)).unwrap(); 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/debug_sftp_client.sh b/demo/sftp/std/debug_sftp_client.sh new file mode 100755 index 00000000..a2e5e4ee --- /dev/null +++ b/demo/sftp/std/debug_sftp_client.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +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..6b1cb278 --- /dev/null +++ b/demo/sftp/std/src/demofilehandlemanager.rs @@ -0,0 +1,63 @@ +use sunset_sftp::handles::{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, + V: PathFinder, +{ + handle_map: HashMap, +} + +impl DemoFileHandleManager +where + K: OpaqueFileHandle, + V: PathFinder, +{ + pub fn new() -> Self { + Self { handle_map: HashMap::new() } + } +} + +impl OpaqueFileHandleManager for DemoFileHandleManager +where + K: OpaqueFileHandle, + V: PathFinder, +{ + type Error = 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::new( + format!("{:}-{:}", &private_handle.get_path_ref(), salt).as_str(), + ); + + 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..67c2fc6b --- /dev/null +++ b/demo/sftp/std/src/demoopaquefilehandle.rs @@ -0,0 +1,37 @@ +use sunset_sftp::handles::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 new(seed: &str) -> Self { + let mut hasher = FnvHasher::default(); + hasher.write(seed.as_bytes()); + DemoOpaqueFileHandle { tiny_hash: (hasher.finish() as u32).to_be_bytes() } + } + + 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)) + } +} diff --git a/demo/sftp/std/src/demosftpserver.rs b/demo/sftp/std/src/demosftpserver.rs new file mode 100644 index 00000000..9d7fd826 --- /dev/null +++ b/demo/sftp/std/src/demosftpserver.rs @@ -0,0 +1,445 @@ +use crate::{ + demofilehandlemanager::DemoFileHandleManager, + demoopaquefilehandle::DemoOpaqueFileHandle, +}; + +use sunset_sftp::error::SftpResult; +use sunset_sftp::handles::{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, +} + +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 { + // TODO What if the base_path does not exist? Create it or Return error? + DemoSftpServer { base_path, handles_manager: DemoFileHandleManager::new() } + } +} + +impl SftpServer<'_, DemoOpaqueFileHandle> 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; + + debug!( + "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 { + debug!("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: &DemoOpaqueFileHandle, + ) -> SftpOpResult<()> { + if let Some(handle) = self.handles_manager.remove(opaque_file_handle) { + match handle { + PrivatePathHandle::File(private_file_handle) => { + debug!( + "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) => { + debug!( + "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: &DemoOpaqueFileHandle, + 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: &DemoOpaqueFileHandle, + 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: &DemoOpaqueFileHandle, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + debug!("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() { + debug!("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 stats( + &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..526f7ebf --- /dev/null +++ b/demo/sftp/std/src/main.rs @@ -0,0 +1,229 @@ +use sunset::*; +use sunset_async::{ProgressHolder, SSHServer, SunsetMutex, SunsetRawMutex}; +use sunset_sftp::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; 512]; + + match { + let stdio = serv.stdio(ch).await?; + let mut file_server = DemoSftpServer::new( + "./demo/sftp/std/testing/out/".to_string(), + ); + + SftpHandler::::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::Trace) + .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..58768bc9 --- /dev/null +++ b/demo/sftp/std/testing/log_demo_sftp_with_test.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +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_single_long.sh b/demo/sftp/std/testing/log_get_single_long.sh new file mode 100755 index 00000000..f01b186d --- /dev/null +++ b/demo/sftp/std/testing/log_get_single_long.sh @@ -0,0 +1 @@ +./log_demo_sftp_with_test.sh ./test_get_long.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/log_get_single_short.sh b/demo/sftp/std/testing/log_get_single_short.sh new file mode 100755 index 00000000..58342c33 --- /dev/null +++ b/demo/sftp/std/testing/log_get_single_short.sh @@ -0,0 +1 @@ +./log_demo_sftp_with_test.sh ./test_get_short.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/test_get.sh b/demo/sftp/std/testing/test_get.sh new file mode 100755 index 00000000..913e1741 --- /dev/null +++ b/demo/sftp/std/testing/test_get.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +echo "Testing Multiple GETs..." + +echo "Cleaning up previous run files" +rm -f -r ./*_random ./out/*_random + + +# 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" "2048kB_random") + + +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=./512B_random 2>/dev/null +dd if=/dev/random bs=1024 count=16 of=./16kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=64 of=./64kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=65 of=./65kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=256 of=./256kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=1024 of=./1024kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=2048 of=./2048kB_random 2>/dev/null +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +echo "Moving to the server folder..." +for file in "${FILES[@]}"; do + mv "./${file}" "./out/${file}" +done + +echo "Output folder content:" + +ls ./out -l + +echo "Downloading files..." +sftp -vvvvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf 'get ./%s\n' "${FILES[@]}") + +bye +EOF + +echo "DOWNLOAD Test Results:" +echo "=============" +# Test each file +for file in "${FILES[@]}"; do + if diff "./${file}" "./out/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r ./${file} ./out/${file} + else + echo "Download FAIL: ${file}". Keeping for inspection + fi +done diff --git a/demo/sftp/std/testing/test_get_long.sh b/demo/sftp/std/testing/test_get_long.sh new file mode 100755 index 00000000..69c60a04 --- /dev/null +++ b/demo/sftp/std/testing/test_get_long.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +echo "Testing Single long GETs..." + +echo "Cleaning up previous run files" +rm -f -r ./*_random ./out/*_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=./100MB_random 2>/dev/null +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +echo "Moving to the server folder..." +for file in "${FILES[@]}"; do + mv "./${file}" "./out/${file}" +done + +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 "./${file}" "./out/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r ./${file} ./out/${file} + else + echo "Download FAIL: ${file}". Keeping for inspection + fi +done diff --git a/demo/sftp/std/testing/test_get_short.sh b/demo/sftp/std/testing/test_get_short.sh new file mode 100755 index 00000000..6d5b5799 --- /dev/null +++ b/demo/sftp/std/testing/test_get_short.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +echo "Testing Single long GETs..." + +echo "Cleaning up previous run files" +rm -f -r ./*_random ./out/*_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=./1MB_random 2>/dev/null +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +echo "Moving to the server folder..." +for file in "${FILES[@]}"; do + mv "./${file}" "./out/${file}" +done + +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 "./${file}" "./out/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r ./${file} ./out/${file} + else + echo "Download FAIL: ${file}". Keeping for inspection + fi +done diff --git a/demo/sftp/std/testing/test_long_write_requests.sh b/demo/sftp/std/testing/test_long_write_requests.sh new file mode 100755 index 00000000..71813ede --- /dev/null +++ b/demo/sftp/std/testing/test_long_write_requests.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# 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=./100MB_random 2>/dev/null +# dd if=/dev/random bs=1048576 count=1024 of=./1024MB_random 2>/dev/null + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} -vvv << EOF +$(printf 'put ./%s\n' "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +for file in "${FILES[@]}"; do + if diff "./${file}" "./out/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + else + echo "FAIL: ${file}" + fi +done + +echo "Cleaning up local files..." +rm -f -r ./*_random ./out/*_random + +echo "Upload test completed." \ No newline at end of file diff --git a/demo/sftp/std/testing/test_read_dir.sh b/demo/sftp/std/testing/test_read_dir.sh new file mode 100755 index 00000000..ec2f18d3 --- /dev/null +++ b/demo/sftp/std/testing/test_read_dir.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# 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=./512B_random 2>/dev/null + +# Generating copies of the test file +echo "Creating copies for each test file..." +for file in "${FILES[@]}"; do + cp ./512B_random "./${file}" + echo "Created: ${file}" +done +ls + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -vvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf 'put ./%s\n' "${FILES[@]}") +ls -lh +bye +EOF + +echo "Cleaning up local files..." +rm -f -r ./*_random ./out/*_random + diff --git a/demo/sftp/std/testing/test_stats.sh b/demo/sftp/std/testing/test_stats.sh new file mode 100755 index 00000000..a5c2ceb5 --- /dev/null +++ b/demo/sftp/std/testing/test_stats.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +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=./512B_random 2>/dev/null + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -vvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf 'put ./%s\n' "${FILES[@]}") +$(printf 'ls -lh ./%s\n' "${FILES[@]}") + +bye +EOF + +echo "Cleaning up local files..." +rm -f -r ./*_random ./out/*_random diff --git a/demo/sftp/std/testing/test_write_requests.sh b/demo/sftp/std/testing/test_write_requests.sh new file mode 100755 index 00000000..cabab6b2 --- /dev/null +++ b/demo/sftp/std/testing/test_write_requests.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# 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=./512B_random 2>/dev/null +dd if=/dev/random bs=1024 count=16 of=./16kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=64 of=./64kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=65 of=./65kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=256 of=./256kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=1024 of=./1024kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=2048 of=./2048kB_random 2>/dev/null + + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf 'put ./%s\n' "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +for file in "${FILES[@]}"; do + if diff "./${file}" "./out/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + else + echo "FAIL: ${file}" + fi +done + +echo "Cleaning up local files..." +rm -f ./*_random ./out/*_random + +echo "Upload test completed." \ No newline at end of file diff --git a/demo/std/src/main.rs b/demo/std/src/main.rs index 8e7ef25a..a516b52a 100644 --- a/demo/std/src/main.rs +++ b/demo/std/src/main.rs @@ -173,7 +173,7 @@ async fn listen( stack: Stack<'static>, config: &'static SunsetMutex, ) -> ! { - let demo = StdDemo::default(); + let demo = StdDemo; demo_common::listen(stack, config, &demo).await } diff --git a/demo/std/src/setupmenu.rs b/demo/std/src/setupmenu.rs index 2da2faed..cfac3a45 100644 --- a/demo/std/src/setupmenu.rs +++ b/demo/std/src/setupmenu.rs @@ -96,11 +96,11 @@ const AUTH_ITEM: Item = Item { }; fn enter_top(context: &mut AsyncMenuBuf) { - let _ = writeln!(context, "In setup menu").unwrap(); + writeln!(context, "In setup menu").unwrap(); } fn enter_auth(context: &mut AsyncMenuBuf) { - let _ = writeln!(context, "In auth menu").unwrap(); + writeln!(context, "In auth menu").unwrap(); } fn do_auth_show( diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml index d585b97c..f9af29d7 100644 --- a/sftp/Cargo.toml +++ b/sftp/Cargo.toml @@ -1,8 +1,23 @@ [package] name = "sunset-sftp" -version = "0.1.0" +version = "0.1.2" edition = "2024" +[features] +default = [] + +# Standard library support - enables std helpers +std = [] + [dependencies] -sunset = { version = "0.2.0", path = "../" } +sunset = { version = "0.3.0", path = "../" } +sunset-async = { path = "../async", version = "0.3" } sunset-sshwire-derive = { version = "0.2", path = "../sshwire-derive" } + + +embedded-io-async = "0.6" +num_enum = {version = "0.7.4", default-features = false} +paste = "1.0" +log = "0.4" +embassy-sync = "0.7.2" +embassy-futures = "0.1.2" diff --git a/sftp/src/asyncsftpserver.rs b/sftp/src/asyncsftpserver.rs new file mode 100644 index 00000000..33458479 --- /dev/null +++ b/sftp/src/asyncsftpserver.rs @@ -0,0 +1,87 @@ +use crate::proto::{Attrs, FileHandle, Name, StatusCode}; + +use core::marker::PhantomData; + +pub type SftpOpResult = core::result::Result; + +/// 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 AsyncSftpServer { + type Handle<'a>: Into> + TryFrom> + Debug + Copy; + + /// Opens a file or directory for reading/writing + async fn open<'a>( + filename: &str, + attrs: &Attrs, + ) -> SftpOpResult> { + log::error!("SftpServer Open operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + /// Close either a file or directory handle + async fn close<'a>(&mut self, handle: &FileHandle<'a>) -> SftpOpResult<()> { + log::error!("SftpServer Close operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + async fn read<'a>( + handle: &FileHandle<'a>, + offset: u64, + reply: &mut ReadReply<'_, '_>, + ) -> SftpOpResult<()> { + log::error!("SftpServer Read operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + async fn write<'a>( + handle: &FileHandle<'a>, + offset: u64, + buf: &[u8], + ) -> SftpOpResult<()> { + log::error!("SftpServer Write operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + async fn opendir<'a>(&mut self, dir: &str) -> SftpOpResult> { + log::error!("SftpServer OpenDir operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + async fn readdir<'a>( + handle: &FileHandle<'a>, + reply: &mut DirReply<'_, '_>, + ) -> SftpOpResult<()> { + log::error!("SftpServer ReadDir operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + + /// Provides the real path of the directory specified + async fn realpath(&mut self, dir: &str) -> SftpOpResult> { + log::error!("SftpServer RealPath operation not defined"); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } +} + +pub struct ReadReply<'g, 'a> { + chan: ChanOut<'g, 'a>, +} + +impl<'g, 'a> ReadReply<'g, 'a> { + pub async fn reply(self, data: &[u8]) {} +} + +pub struct DirReply<'g, 'a> { + chan: ChanOut<'g, 'a>, +} + +impl<'g, 'a> DirReply<'g, 'a> { + pub async fn reply(self, data: &[u8]) {} +} + +// TODO Implement correct Channel Out +pub struct ChanOut<'g, 'a> { + _phantom_g: PhantomData<&'g ()>, + _phantom_a: PhantomData<&'a ()>, +} diff --git a/sftp/src/bin/read_packets_from_file.rs b/sftp/src/bin/read_packets_from_file.rs new file mode 100644 index 00000000..dcd07012 --- /dev/null +++ b/sftp/src/bin/read_packets_from_file.rs @@ -0,0 +1,90 @@ +use std::env; +use std::fs::File; +use std::io::{BufRead, BufReader}; + +use sunset::sshwire::{SSHDecode, SSHSource}; +use sunset_sftp::SftpSource; +use sunset_sftp::protocol::{NameEntry, SftpPacket}; +/// This program reads packets from a specified file and prints their byte representation. +/// +/// We assume that each line contains an u8. a way to get this format is to use the +/// `demo/sftp/std/testing/extract_txrx.sh` on a sunset-demo-sftp-std log file. +/// +/// # Usage +/// ``` +/// cargo run --package sunset-sftp --bin read_packets_from_file +/// ``` +/// where `` is the path to the file containing the packets. +/// +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + + let file_path = &args[1]; + let file = File::open(file_path).expect("Failed to open file"); + let reader = BufReader::new(file); + let mut bytes: Vec = Vec::new(); + for (i, line) in reader.lines().enumerate() { + match line { + Ok(content) => { + let num = content + .parse::() + .expect(format!("Failed to parse line {i} as u8").as_str()); + bytes.push(num); + } + Err(e) => eprintln!("Error reading line {}: {}", i, e), + } + } + println!("Read {} u8 elements from file {}", bytes.len(), file_path); + let mut used = 0; + let mut source = SftpSource::new(&bytes.as_slice()); + while source.remaining() > 0 { + match SftpPacket::decode(&mut source) { + Ok(packet) => match packet { + SftpPacket::Name(req_id, name) => { + println!("SFTP Name: {:?} {:?}", req_id, name); + for i in 0..name.count { + println!( + "--({i}) Entry: {:?}", + NameEntry::dec(&mut source) + .expect("Failed to decode NameEntry") + ); + } + } + SftpPacket::Handle(req_id, handle) => { + println!("SFTP Handle: {:?} {:?}", req_id, handle); + } + SftpPacket::Attrs(req_id, attrs) => { + println!("SFTP Attrs: {:?} {:?}", req_id, attrs); + } + SftpPacket::Data(req_id, data) => { + println!("SFTP Data: {:?} {:?} bytes", req_id, data); + } + _ => { + println!("Decoded packet: {:?}", packet); + } + }, + Err(e) => { + println!( + "Error decoding packet: {:?}. Up to: {:?}", + e, + source.buffer_used().len() + ); + + break; + } + } + let prev_used = used; + used = source.buffer_used().len(); + let last_used = used - prev_used; + println!( + "Last 9 bytes : {:?}, Lines {:?}-{used}, Counters: ({last_used}/{used}) [last/total decoded]\n", + &source.buffer_used()[used - 9..], + prev_used + 1 + ); + } +} diff --git a/sftp/src/lib.rs b/sftp/src/lib.rs index 4c2f3ffe..46c2670c 100644 --- a/sftp/src/lib.rs +++ b/sftp/src/lib.rs @@ -1,2 +1,126 @@ +//! 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) +//! - [ ] File [read](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4), +//! - [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; +} + +/// Handles and helpers used by the [`sftpserver::SftpServer`] trait implementer +pub mod handles { + 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..19450ef1 --- /dev/null +++ b/sftp/src/opaquefilehandle.rs @@ -0,0 +1,70 @@ +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 instance using a given string slice as `seed` which + /// content should not clearly related to the seed + fn new(seed: &str) -> Self; + + /// 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; +} + +/// This trait is used to manage the OpaqueFile +/// +/// 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, + V: PathFinder, +{ + /// The error used for all the trait members returning an error + type Error; + + // Excluded since it is too restrictive + // /// Performs any HandleManager Initialization + // fn new() -> Self; + + /// 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 index 9c5928df..962ade5f 100644 --- a/sftp/src/proto.rs +++ b/sftp/src/proto.rs @@ -1,113 +1,449 @@ -use core::marker::PhantomData; +use crate::sftpsource::SftpSource; -use sshwire::{BinString, TextString, SSHEncode, SSHDecode, SSHSource, SSHSink, WireResult, WireError}; -use sunset::{error, Result}; +use sunset::sshwire::{ + BinString, SSHDecode, SSHEncode, 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 `SSH_FXP_READDIR` +/// +/// (4 + 256) bytes for path, (4 + 0) bytes for empty long path and 72 bytes for the attributes ( 32/4*7 + 64/4 * 1 = 72) +pub const MAX_NAME_ENTRY_SIZE: usize = 4 + 256 + 4 + 72; // TODO is utf8 enough, or does this need to be an opaque binstring? -#[derive(Debug)] +/// 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>); -#[derive(Debug, SSHEncode, SSHDecode)] -struct FileHandle<'a>(pub BinString<'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 InitVersion<'a> { +pub struct InitVersionClient { // No ReqId for SSH_FXP_INIT pub version: u32, // TODO variable number of ExtPair - pub _ext: &'a PhantomData<()>, } +/// 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>, - pub pflags: u32, - pub attrs: Attrs<'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) +/// TODO: Reference! This is packed as u32 since that is the field data type in specs +#[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)?)) + } +} + +// TODO: Implement an automatic from implementation for u32 to Status code +// This is prone to errors if we update PFlags enum +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, - pub data: BinString<'a>, + + pub data_len: u32, + // pub data: BinString<'a>, // TODO: Find an elegant way to process the write process +} + +// TODO: This cannot work because we would need a length field +// #[derive(Debug, SSHEncode, SSHDecode)] +// pub struct WriteData<'a> { +// pub data_slice: &'a [u8], +// } + +/// 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>, } -// Responses +/// 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>, } -#[derive(Debug, SSHEncode, SSHDecode)] +/// 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> { - pub handle: FileHandle<'a>, - pub offset: u64, + /// raw data pub data: BinString<'a>, } -#[derive(Debug, SSHEncode, SSHDecode)] -pub struct Name<'a> { - pub count: u32, - // TODO repeat NameEntry -} +/// 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>, - pub attrs: Attrs<'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, } -#[derive(Debug, SSHEncode, SSHDecode, Clone, Copy)] +/// 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); -#[derive(Debug, SSHEncode, SSHDecode)] -#[repr(u8)] -#[allow(non_camel_case_types)] -enum StatusCode { +/// For more information see [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +/// TODO: Reference! This is packed as u32 since that is the field data type in specs +#[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, - Other(u8), + // #[sshwire(unknown)] + #[num_enum(catch_all)] + Other(u32), } -#[derive(Debug, SSHEncode, SSHDecode)] -pub struct ExtPair<'a> { - pub name: &'a str, - pub data: BinString<'a>, +impl<'de> SSHDecode<'de> for StatusCode { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(StatusCode::from(u32::dec(s)?)) + } } -#[derive(Debug)] -pub struct Attrs<'a> { - // flags: u32, defines used attributes +// TODO: Implement an automatic from implementation for u32 to Status code +// This is prone to errors if we update StatusCode enum +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, @@ -118,211 +454,657 @@ pub struct Attrs<'a> { // TODO extensions } -enum Error { - UnknownPacket { number: u8 }, -} - -macro_rules! sftpmessages { - ( - $( ( $message_num:literal, - $SpecificPacketVariant:ident, - $SpecificPacketType:ty, - $SSH_FXP_NAME:ident - ), - )* - ) => { - - -#[derive(Debug)] -#[repr(u8)] +/// 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 SftpNum { - // variants are eg - // SSH_FXP_OPEN = 3, - $( - $SSH_FXP_NAME = $message_num, - )* -} - -impl SftpNum { - fn is_request(&self) -> bool { - // TODO SSH_FXP_EXTENDED - (2..=99).contains(self as u8) +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 is_response(&self) -> bool { - // TODO SSH_FXP_EXTENDED_REPLY - (100..=199).contains(self as u8) + fn bitand(self, rhs: AttrsFlags) -> Self::Output { + self & rhs as u32 } } -impl TryFrom for SftpNum { - type Error = Error; - fn try_from(v: u8) -> Result { - match v { - // eg - // 3 => Ok(SftpNum::SSH_FXP_OPEN) - $( - $message_num => Ok(SftpNum::$SSH_FXP_NAME), - )* - _ => { - Err(Error::UnknownPacket { number: v }) - } +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 SftpPacket<'_> { +impl SSHEncode for Attrs { fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { - let t = self.message_num() as u8; - t.enc(s)?; - match self { - // eg - // Packet::KexInit(p) => { - // ... - $( - Packet::$SpecificPacketVariant(p) => { - p.enc(s)? - } - )* - }; + 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: 'a, 'a> SSHDecode<'de> for SftpPacket<'a> { +impl<'de> SSHDecode<'de> for Attrs { fn dec(s: &mut S) -> WireResult - where S: SSHSource<'de> { - let msg_num = u8::dec(s)?; - let ty = MessageNumber::try_from(msg_num); - let ty = match ty { - Ok(t) => t, - Err(_) => return Err(WireError::UnknownPacket { number: msg_num }) - }; + 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{ - // Decode based on the message number - let p = match ty { - // eg - // MessageNumber::SSH_MSG_KEXINIT => Packet::KexInit( - // ... - $( - MessageNumber::$SSH_MESSAGE_NAME => Packet::$SpecificPacketVariant(SSHDecode::dec(s)?), - )* - }; - Ok(p) + Ok(attrs) } } -/// Top level SSH packet enum -#[derive(Debug)] -pub enum SftpPacket<'a> { - // eg Open(Open<'a>), - $( - $SpecificPacketVariant($SpecificPacketType), - )* -} - -impl<'a> SftpPacket<'a> { - pub fn sftp_num(&self) -> SftpNum { - match self { - // eg - // SftpPacket::Open(_) => { - // .. - $( - SftpPacket::$SpecificPacketVariant(_) => { - MessageNumber::$SSH_FXP_NAME +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, SSHEncode)] + #[repr(u8)] + #[allow(non_camel_case_types)] + pub enum SftpNum { + $( + #[sshwire(variant = $init_ssh_fxp_name)] + [<$init_ssh_fxp_name:upper>] = $init_message_num, + )* + + $( + #[sshwire(variant = $request_ssh_fxp_name)] + [<$request_ssh_fxp_name:upper>] = $request_message_num, + )* + + $( + #[sshwire(variant = $response_ssh_fxp_name)] + [<$response_ssh_fxp_name:upper>] = $response_message_num, + )* + + #[sshwire(unknown)] + #[num_enum(catch_all)] + Other(u8), + } + } // 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 + + } + } - /// 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) -> Result<()> { - if !self.sftp_num().is_request() { - return Err(Error::bug()) } - // packet type - self.sftp_num().enc(s)?; - // request ID - id.0.enc(s)?; - // contents - self.enc(s) - } + } //paste - /// Decode a response. - /// - /// Used by a SFTP client. Does not include the length field. - pub fn decode_response(s: &mut dyn SSHSource) -> WireResult<(ReqId, Self)> { - let num = SftpNum::try_from(u8::dec(s)?)?; + 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()))) + } - if !num.is_response() { - return error::SSHProto.fail(); + fn is_response(&self) -> bool { + // TODO SSH_FXP_EXTENDED_REPLY + (100..=105).contains(&(u8::from(self.clone()))) + ||(2..=2).contains(&(u8::from(self.clone()))) + } } - let id = ReqId(u32::dec(s)?); - Ok((id, Self::dec(s))) - } - /// Decode a request. - /// - /// Used by a SFTP server. Does not include the length field. - pub fn decode_request(s: &mut dyn SSHSource) -> WireResult<(ReqId, Self)> { - let num = SftpNum::try_from(u8::dec(s)?)?; + /// 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), + )* - if !num.is_request() { - return error::SSHProto.fail(); } - let id = ReqId(u32::dec(s)?); - Ok((id, Self::dec(s))) - } - /// Encode a response. - /// - /// Used by a SFTP server. Does not include the length field. - pub fn encode_response(&self, id: ReqId, s: &mut dyn SSHSink) -> Result<()> { - if !self.sftp_num().is_response() { - return Err(Error::bug()) + 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(()) + } } - // packet type - self.sftp_num().enc(s)?; - // request ID - id.0.enc(s)?; - // contents - self.enc(s) - } -} + paste!{ -$( -impl<'a> From<$SpecificPacketType> for SftpPacket<'a> { - fn from(s: $SpecificPacketType) -> SftpPacket<'a> { - SftpPacket::$SpecificPacketVariant(s) - } -} -)* -} } // macro + impl<'a: 'de, 'de> SSHDecode<'de> for SftpPacket<'a> + where 'de: 'a // This implies that both lifetimes are equal + { + fn dec(s: &mut S) -> WireResult + where S: SSHSource<'de> { + 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(_,_) => { -sftpmessages![ + 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> { + warn!("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> { + warn!("Casting from {:?} to SftpPacket cannot set Request Id",$response_ssh_fxp_name); + SftpPacket::$response_packet_variant(ReqId(0), s) + } + } + )* + + }; // main macro -// Message number ranges are also used by Sftpnum::is_request and is_response. +} // sftpmessages macro -(1, Init, InitVersion<'a>, SSH_FXP_INIT), -(2, Version, InitVersion<'a>, SSH_FXP_VERSION), +sftpmessages! [ -// Requests -(3, Open, Open<'a>, SSH_FXP_OPEN), -(4, Close, Close<'a>, SSH_FXP_CLOSE), -(5, Read, Read<'a>, SSH_FXP_READ), + init:{ + (1, Init, InitVersionClient, "ssh_fxp_init"), + (2, Version, InitVersionLowest, "ssh_fxp_version"), + }, -// Responses -(101, Status, Status<'a>, SSH_FXP_STATUS), -(102, Handle, Handle<'a>, SSH_FXP_HANDLE), -(103, Data, Data<'a>, SSH_FXP_DATA), -(104, Name, Name<'a>, SSH_FXP_NAME), + 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"), + }, + 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: Create tests for every SftpPacket. A good starting point is a + // roadtrip test + + #[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, + // ext_count: Some(10), // TODO: This does not get deserialized + }; + + 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..8086ccd0 --- /dev/null +++ b/sftp/src/sftperror.rs @@ -0,0 +1,99 @@ +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; + +// TODO Use it more broadly where reasonable +/// 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..e8d3c939 --- /dev/null +++ b/sftp/src/sftphandler/requestholder.rs @@ -0,0 +1,367 @@ +use crate::{ + proto::{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. Consideer 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], + /// 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]) -> 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.peak_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 mut source = SftpSource::new(self.try_get_ref().unwrap_or(&[0])); + match SftpPacket::decode_request(&mut source) { + Ok(request) => { + return Some(request); + } + Err(..) => return None, + } + } + + /// 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::*; + // use crate::requestholder::RequestHolder; + + #[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; 256]; + 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; 256]; + 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; 256]; + 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..d4f3e85f --- /dev/null +++ b/sftp/src/sftphandler/sftphandler.rs @@ -0,0 +1,753 @@ +use crate::error::SftpError; +use crate::handles::OpaqueFileHandle; +use crate::proto::{ + self, InitVersionClient, InitVersionLowest, LStat, 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 + /// - `incomplete_request_buffer`: used to deal with fragmented + /// packets during [`SftpHandler::process`] + pub fn new(file_server: &'a mut S, request_buffer: &'a mut [u8]) -> Self { + SftpHandler { + file_server, + // partial_write_request_tracker: None, + state: HandlerState::default(), + request_holder: RequestHolder::new(request_buffer), + _marker: core::marker::PhantomData, + } + } + + /// - 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())?; + + buf = &buf[buf.len() - source.remaining()..]; + + // 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 + .stats(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 + .stats(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::Status(req_id, status) => todo!(), + // SftpPacket::Handle(req_id, handle) => todo!(), + // SftpPacket::Data(req_id, data) => todo!(), + // SftpPacket::Name(req_id, name) => todo!(), + // SftpPacket::Attrs(req_id, attrs) => todo!(), + _ => { + + // TODO: Use a catch all + } + } + } 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(()) + } + + /// 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 + } + } + } +} diff --git a/sftp/src/sftphandler/sftpoutputchannelhandler.rs b/sftp/src/sftphandler/sftpoutputchannelhandler.rs new file mode 100644 index 00000000..5f6b9f63 --- /dev/null +++ b/sftp/src/sftphandler/sftpoutputchannelhandler.rs @@ -0,0 +1,217 @@ +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, + } + } + + // TODO: Check if it panics when called twice + /// 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<()> { + // TODO: Revert to the simpler version once the root cause of the stall is found + // 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}"); + // if rl > 0 { + // self.ssh_chan_out.write_all(&buf[..rl]).await?; + // debug!("Output Consumer: Written {:?} bytes ", &buf[..rl].len()); + // trace!("Output Consumer: Bytes written {:?}", &buf[..rl]); + // } else { + // error!("Output Consumer: Empty array received"); + // } + // } + 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 index 6623b989..c43201f2 100644 --- a/sftp/src/sftpserver.rs +++ b/sftp/src/sftpserver.rs @@ -1,35 +1,667 @@ -use proto::{StatusCode, Attrs}; +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}, +}; -pub type Result = core::result::Result; +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 { + // TODO Ideally this will contain an OwnedFileHandle + /// 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)`. -trait SftpServer { - type Handle; - - // TODO flags struct - async fn open(filename: &str, flags: u32, attrs: &Attrs) -> Result; +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 - async fn close(handle: &Self::Handle) -> Result<()>; + 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 stats of the given file path + fn stats( + &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) + } + } +} +// TODO Define this +/// 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); - async fn read(handle: &Self::Handle, offset: u64, reply: &mut ReadReply) -> Result<()>; + let payload = + DirReply::::encode_data_header(&mut sink, req_id, data_len, count) + .unwrap(); - async fn write(handle: &Self::Handle, offset: u64, buf: &[u8]) -> Result<()>; + // println!("{payload:?}"); - async fn opendir(dir: &str) -> Result; + // 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, + }; - async fn readdir(handle: &Self::Handle, reply: &mut DirReply) -> Result<()>; + 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) + } } -pub struct ReadReply<'g, 'a> { - chan: ChanOut<'g, 'a>, +#[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`] +/// +/// WIP: Not stable. It has know issues and most likely it's methods will change +/// +/// TODO: It does not include longname and that may be an issue +#[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, } -impl<'g, 'a> ReadReply<'g, 'a> { - pub async fn reply(self, data: &[u8]) { - +#[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) -> Self { + 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()?; + //TODO remove this unchecked casting + encoded_length += sftp_sink.payload_len() as u32; + Some(entry) + }) + .collect(); + + //TODO remove this unchecked casting + let count = entries.len() as u32; + + info!( + "Processed {} entries, estimated serialized length: {}", + count, encoded_length + ); + + 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..ad2ee18e --- /dev/null +++ b/sftp/src/sftpsink.rs @@ -0,0 +1,100 @@ +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 } + } + + // TODO: Why don't you compute this every time that a new field is added? + /// 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..6818c2d4 --- /dev/null +++ b/sftp/src/sftpsource.rs @@ -0,0 +1,238 @@ +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 } + } + /// Peaks 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 peak_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) + } + + // TODO: Test This for correctness + /// 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.peak_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/auth.rs b/src/auth.rs index 30b10dab..1c144c3f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -4,16 +4,8 @@ use { log::{debug, error, info, log, trace, warn}, }; -use core::task::{Poll, Waker}; -use heapless::{String, Vec}; - use crate::*; -use client::*; use kex::SessId; -use packets::ParseContext; -use packets::{Packet, Signature, Userauth60}; -use sign::SignKey; -use sshnames::*; use sshwire::{BinString, SSHEncode, WireResult}; /// The message to be signed in a pubkey authentication message, diff --git a/src/channel.rs b/src/channel.rs index 7fcf6de5..83284429 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -8,26 +8,22 @@ use { use core::num::NonZeroUsize; use core::task::Waker; -use core::{marker::PhantomData, mem}; -use heapless::{Deque, String, Vec}; +use heapless::{String, Vec}; use crate::{runner::set_waker, *}; use config::*; use conn::DispatchEvent; -use conn::Dispatched; use event::{CliEventId, ServEventId}; use packets::{ - ChannelData, ChannelDataExt, ChannelOpen, ChannelOpenFailure, ChannelOpenType, - ChannelReqType, ChannelRequest, Packet, + ChannelData, ChannelDataExt, ChannelOpen, ChannelOpenType, ChannelReqType, + ChannelRequest, Packet, }; use runner::ChanHandle; use sshnames::*; use sshwire::{BinString, SSHEncodeEnum, TextString}; use traffic::TrafSend; -use snafu::ErrorCompat; - pub(crate) struct Channels { ch: [Option; config::MAX_CHANNELS], is_client: bool, @@ -73,7 +69,7 @@ impl Channels { let ch = self.get_any(num)?; match ch.state { - ChanState::InOpen | ChanState::Opening { .. } => { + ChanState::InOpen | ChanState::Opening => { error::BadChannel { num }.fail() } _ => Ok(ch), @@ -94,18 +90,14 @@ impl Channels { let ch = self.get_any_mut(num)?; match ch.state { - ChanState::InOpen | ChanState::Opening { .. } => { + ChanState::InOpen | ChanState::Opening => { error::BadChannel { num }.fail() } _ => Ok(ch), } } - pub fn _from_handle(&self, handle: &ChanHandle) -> &Channel { - self.get(handle.0).unwrap() - } - - pub fn from_handle_mut(&mut self, handle: &ChanHandle) -> &mut Channel { + pub fn by_handle_mut(&mut self, handle: &ChanHandle) -> &mut Channel { self.get_mut(handle.0).unwrap() } @@ -219,17 +211,26 @@ impl Channels { let ch = self.get_mut(num)?; ch.finished_input(len); if let Some(w) = ch.check_window_adjust()? { - s.send(w)?; + // The send buffer may be full. Ignore the failure and hope another adjustment is + // sent later. TODO improve this. + 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(()) } pub(crate) fn have_recv_eof(&self, num: ChanNum) -> bool { - self.get(num).map_or(false, |c| c.have_recv_eof()) + self.get(num).is_ok_and(|c| c.have_recv_eof()) } pub(crate) fn is_closed(&self, num: ChanNum) -> bool { - self.get(num).map_or(false, |c| c.is_closed()) + self.get(num).is_ok_and(|c| c.is_closed()) } pub(crate) fn send_allowed(&self, num: ChanNum) -> Option { @@ -237,7 +238,7 @@ impl Channels { } pub(crate) fn valid_send(&self, num: ChanNum, dt: ChanData) -> bool { - self.get(num).map_or(false, |c| c.valid_send(dt)) + self.get(num).is_ok_and(|c| c.valid_send(dt)) } /// Wake the channel with a ready input data packet. @@ -467,18 +468,13 @@ impl Channels { // Discard the data, sunset can't handle this debug!("Ignoring unexpected dt data, code {}", p.code); ch.finished_input(p.data.0.len()); + } else if let Some(len) = NonZeroUsize::new(p.data.0.len()) { + // TODO check we are expecting input and dt is valid. + let di = + DataIn { num: ChanNum(p.num), dt: ChanData::Stderr, len }; + ev = DispatchEvent::Data(di); } else { - if let Some(len) = NonZeroUsize::new(p.data.0.len()) { - // TODO check we are expecting input and dt is valid. - let di = DataIn { - num: ChanNum(p.num), - dt: ChanData::Stderr, - len, - }; - ev = DispatchEvent::Data(di); - } else { - trace!("Zero length channeldataext"); - } + trace!("Zero length channeldataext"); } } Packet::ChannelEof(p) => { @@ -565,7 +561,28 @@ impl Channels { req: ChannelReqType::Subsystem(packets::Subsystem { subsystem: command }), .. - }) => Ok(command.clone()), + }) => Ok(*command), + _ => Err(Error::bug()), + } + } + + pub fn fetch_env_name<'p>(&self, p: &Packet<'p>) -> Result> { + match p { + Packet::ChannelRequest(ChannelRequest { + req: ChannelReqType::Environment(packets::Environment { name, .. }), + .. + }) => Ok(*name), + _ => Err(Error::bug()), + } + } + + pub fn fetch_env_value<'p>(&self, p: &Packet<'p>) -> Result> { + match p { + Packet::ChannelRequest(ChannelRequest { + req: + ChannelReqType::Environment(packets::Environment { name: _, value }), + .. + }) => Ok(*value), _ => Err(Error::bug()), } } @@ -815,22 +832,30 @@ impl Channel { pub fn wake_read(&mut self, dt: ChanData, is_client: bool) { match dt { ChanData::Normal => { - self.read_waker.take().map(|w| w.wake()); + if let Some(w) = self.read_waker.take() { + w.wake() + } } ChanData::Stderr => { if is_client { - self.ext_waker.take().map(|w| w.wake()); + if let Some(w) = self.ext_waker.take() { + w.wake() + } } } } } pub fn wake_write(&mut self, dt: Option, is_client: bool) { - if dt == Some(ChanData::Normal) || dt == None { - self.read_waker.take().map(|w| w.wake()); + if dt == Some(ChanData::Normal) || dt.is_none() { + if let Some(w) = self.read_waker.take() { + w.wake() + } } - if !is_client && (dt == Some(ChanData::Normal) || dt == None) { - self.ext_waker.take().map(|w| w.wake()); + if !is_client && (dt == Some(ChanData::Normal) || dt.is_none()) { + if let Some(w) = self.ext_waker.take() { + w.wake() + } } } @@ -900,6 +925,9 @@ impl Channel { ChannelReqType::Pty(_) => { Ok(DispatchEvent::ServEvent(ServEventId::SessionPty { num })) } + ChannelReqType::Environment(_) => { + Ok(DispatchEvent::ServEvent(ServEventId::Environment { num })) + } _ => { if let ChannelReqType::Unknown(u) = &p.req { warn!("Unknown channel req type \"{}\"", u) @@ -1009,11 +1037,12 @@ impl Channel { } /// Returns a window adjustment packet if required - fn check_window_adjust(&mut self) -> Result>> { - let num = self.send.as_mut().trap()?.num; + /// + /// Does not reset the adjustment to 0, should be done by caller on successful send. + fn check_window_adjust(&self) -> Result>> { + let num = self.send.as_ref().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 { @@ -1133,7 +1162,6 @@ impl<'g, 'a> CliSessionOpener<'g, 'a> { /// /// This must be sent prior to requesting a shell or command. /// Shells using a PTY will only receive data on the stdin FD, not stderr. - // TODO: set a flag in the channel so that it drops data on stderr, to // avoid waiting forever for a consumer? pub fn pty(&mut self, pty: channel::Pty) -> Result<()> { diff --git a/src/cliauth.rs b/src/cliauth.rs index b3f99e64..d0389c5d 100644 --- a/src/cliauth.rs +++ b/src/cliauth.rs @@ -1,7 +1,4 @@ -use self::{ - conn::DispatchEvent, - event::{CliEvent, CliEventId}, -}; +use self::{conn::DispatchEvent, event::CliEventId}; #[allow(unused_imports)] use { @@ -9,21 +6,15 @@ use { log::{debug, error, info, log, trace, warn}, }; -use core::task::{Poll, Waker}; -use heapless::{String, Vec}; -use pretty_hex::PrettyHex; +use heapless::String; use crate::{packets::UserauthPkOk, *}; use auth::AuthType; -use client::*; use kex::SessId; -use packets::{ - AuthMethod, MessageNumber, MethodPubKey, ParseContext, UserauthRequest, -}; -use packets::{Packet, Signature, Userauth60}; +use packets::{AuthMethod, MethodPubKey, ParseContext}; +use packets::{Packet, Userauth60}; use sign::{OwnedSig, SignKey}; use sshnames::*; -use sshwire::{BinString, Blob}; use traffic::TrafSend; #[derive(Debug)] @@ -117,7 +108,7 @@ impl CliAuth { key: &'b SignKey, sess_id: &'b SessId, ) -> Result> { - let p = req_packet_pubkey(&self.username, &key, None, true)?; + let p = req_packet_pubkey(&self.username, key, None, true)?; Ok(auth::AuthSigMsg::new(p, sess_id)) } @@ -145,7 +136,7 @@ impl CliAuth { // Sign the packet without the signature let msg = self.auth_sig_msg(key, sess_id)?; let sig = key.sign(&msg)?; - let p = req_packet_pubkey(&self.username, &key, Some(&sig), true)?; + let p = req_packet_pubkey(&self.username, key, Some(&sig), true)?; s.send(p)?; parse_ctx.cli_auth_type = None; @@ -169,7 +160,7 @@ impl CliAuth { return Ok(DispatchEvent::CliEvent(CliEventId::Pubkey)); }; - let p = req_packet_pubkey(&self.username, &key, Some(&sig), true)?; + let p = req_packet_pubkey(&self.username, key, Some(sig), true)?; s.send(p)?; Ok(DispatchEvent::None) } diff --git a/src/client.rs b/src/client.rs index 3935901c..b4b41949 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,15 +4,9 @@ use { log::{debug, error, info, log, trace, warn}, }; -use snafu::prelude::*; - -use crate::{packets::ChannelOpen, *}; +use crate::*; use cliauth::CliAuth; -use heapless::String; -use packets::{Packet, ParseContext, PubKey}; -use sign::SignKey; -use sshnames::*; -use traffic::TrafSend; +use packets::ParseContext; #[derive(Default, Debug)] pub struct Client { diff --git a/src/config.rs b/src/config.rs index 4711ee2c..cef59b5e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,7 @@ pub const MAX_USERNAME: usize = 31; /// Maximum username for client or server /// -/// 32 is the limit for various Linux APIs like wtmp +/// 31 is the limit for various Linux APIs like wtmp #[cfg(feature = "larger")] pub const MAX_USERNAME: usize = 256; diff --git a/src/conn.rs b/src/conn.rs index 42d575b3..f7c6108a 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -12,21 +12,14 @@ use { log::{debug, error, info, log, trace, warn}, }; -use core::char::MAX; -use core::task::{Poll, Waker}; - -use heapless::Vec; use pretty_hex::PrettyHex; use crate::*; use channel::{Channels, CliSessionExit}; use client::Client; -use config::MAX_CHANNELS; -use event::{CliEvent, ServEvent}; use kex::{AlgoConfig, Kex, SessId}; use packets::{Packet, ParseContext}; use server::Server; -use sshnames::*; use traffic::TrafSend; /// The core state of a SSH instance. @@ -69,7 +62,7 @@ enum ConnState { // must_use so return values can't be forgotten in Conn::dispatch_packet #[must_use] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub(crate) enum DispatchEvent { /// Incoming channel data Data(channel::DataIn), @@ -80,15 +73,10 @@ pub(crate) enum DispatchEvent { /// Connection state has changed, should poll again Progressed, /// No event + #[default] None, } -impl Default for DispatchEvent { - fn default() -> Self { - Self::None - } -} - impl DispatchEvent { pub fn take(&mut self) -> Self { core::mem::replace(self, DispatchEvent::None) @@ -115,10 +103,7 @@ impl DispatchEvent { } pub(crate) fn is_event(&self) -> bool { - match self { - Self::CliEvent(_) | Self::ServEvent(_) => true, - _ => false, - } + matches!(self, Self::CliEvent(_) | Self::ServEvent(_)) } } @@ -210,7 +195,7 @@ impl CliServ for server::Server { Some(self) } - #[allow(private_interfaces)] + #[expect(private_interfaces)] fn dispatch_into_event<'a, 'g>( runner: &'g mut Runner<'a, Self>, disp: DispatchEvent, @@ -706,7 +691,7 @@ impl Conn { if auth.authed && matches!(self.state, ConnState::PreAuth) { self.state = ConnState::Authed; } - return Ok(()); + Ok(()) } pub(crate) fn resume_servauth_pkok( @@ -717,11 +702,17 @@ impl Conn { let p = self.packet(payload)?; self.server()?.auth.resume_pkok(p, s) } + + pub(crate) fn set_auth_methods( + &mut self, + password: bool, + pubkey: bool, + ) -> Result<()> { + let auth = &mut self.mut_server()?.auth; + auth.set_auth_methods(password, pubkey); + Ok(()) + } } #[cfg(test)] -mod tests { - use crate::conn::*; - use crate::error::Error; - use crate::sunsetlog::*; -} +mod tests {} diff --git a/src/encrypt.rs b/src/encrypt.rs index 40cc3a99..bc520c87 100644 --- a/src/encrypt.rs +++ b/src/encrypt.rs @@ -13,12 +13,8 @@ use core::fmt; use core::fmt::Debug; use core::num::Wrapping; -use aes::{ - cipher::{BlockSizeUser, KeyIvInit, KeySizeUser, StreamCipher}, - Aes256, -}; -use hmac::{Hmac, Mac}; -use pretty_hex::PrettyHex; +use aes::cipher::{BlockSizeUser, KeyIvInit, KeySizeUser, StreamCipher}; +use hmac::Mac; use sha2::Digest as Sha2DigestForTrait; use zeroize::ZeroizeOnDrop; @@ -26,7 +22,6 @@ use crate::*; use kex::{self, SessId}; use ssh_chapoly::SSHChaPoly; use sshnames::*; -use sshwire::hash_mpint; // TODO: check that Ctr32 is sufficient. Should be OK with SSH rekeying. type Aes256Ctr32BE = ctr::Ctr32BE; @@ -135,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 } @@ -420,7 +417,7 @@ impl KeysRecv { let sublength = if self.cipher.is_aead() { SSH_LENGTH_SIZE } else { 0 }; let len = buf.len() - size_integ - sublength; - if len % size_block != 0 { + if !len.is_multiple_of(size_block) { debug!("Bad packet, not multiple of block size"); return error::SSHProto.fail(); } diff --git a/src/error.rs b/src/error.rs index c9a0f433..42333e8b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,15 +2,15 @@ use core::str::Utf8Error; #[allow(unused_imports)] use log::{debug, error, info, log, trace, warn}; -use core::fmt; use core::fmt::Arguments; -use snafu::{prelude::*, Backtrace, Location}; - -use heapless::String; +use snafu::prelude::*; use crate::channel::ChanNum; +#[allow(unused_imports)] +use snafu::{Backtrace, Location}; + // TODO: can we make Snafu not require Debug? /// The Sunset error type. @@ -299,8 +299,4 @@ impl From for Error { } #[cfg(test)] -pub(crate) mod tests { - use crate::error::*; - use crate::packets::Unknown; - use crate::sunsetlog::init_test_log; -} +pub(crate) mod tests {} diff --git a/src/event.rs b/src/event.rs index b185e912..a9842a91 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,19 +1,11 @@ -/// Events used by applications running the SSH connection -/// -/// These include hostkeys, authentication, and shell/command sessions -use self::{ - channel::Channel, - packets::{AuthMethod, MethodPubKey, UserauthRequest}, -}; - #[allow(unused_imports)] use { crate::error::{Error, Result, TrapBug}, log::{debug, error, info, log, trace, warn}, + subtle::ConstantTimeEq, }; use core::fmt::Debug; -use core::mem::Discriminant; use crate::*; use channel::{CliSessionExit, CliSessionOpener}; @@ -59,7 +51,6 @@ pub enum CliEvent<'g, 'a> { // ChanRequest(ChanRequest<'g, 'a>), // Banner { banner: TextString<'a>, language: TextString<'a> }, /// The SSH connection is no longer running - #[allow(unused)] Defunct, /// No event was returned. @@ -197,7 +188,7 @@ pub(crate) enum CliEventId { SessionOpened(ChanNum), SessionExit, Banner, - #[allow(unused)] + #[expect(unused)] Defunct, // TODO: // Disconnected @@ -300,9 +291,11 @@ pub enum ServEvent<'g, 'a> { /// /// TODO details SessionPty(ServPtyRequest<'g, 'a>), + /// Server has received one environment variable. + /// Note: input strings are not sanitised. + SessionEnv(ServEnvironmentRequest<'g, 'a>), /// The SSH session is no longer running - #[allow(unused)] Defunct, /// No event was returned. @@ -325,6 +318,7 @@ impl Debug for ServEvent<'_, '_> { Self::SessionExec(_) => "SessionExec", Self::SessionSubsystem(_) => "SessionSubsystem", Self::SessionPty(_) => "SessionPty", + Self::SessionEnv(_) => "Environment", Self::Defunct => "Defunct", Self::PollAgain => "PollAgain", }; @@ -362,6 +356,17 @@ impl<'g, 'a> ServPasswordAuth<'g, 'a> { self.raw_username()?.as_str() } + /// Perform a constant-time comparison of the user-presented username against a passed string. + pub fn matches_username( + &self, + username: impl core::convert::AsRef, + ) -> bool { + match self.username() { + Ok(u) => u.as_bytes().ct_eq(username.as_ref().as_bytes()).into(), + _ => false, + } + } + /// Retrieve the password presented by the user. /// /// When comparing with an expected password or hash, take @@ -371,6 +376,20 @@ impl<'g, 'a> ServPasswordAuth<'g, 'a> { self.raw_password()?.as_str() } + /// Perform a constant-time comparison of the user-presented password against a passed string. + /// # Caution + /// This is better than a naive comparison, but passwords should be hashed and stored using a + /// platform-appropriate password hashing function. Consider bcrypt, argon2, or pbkdf2. + pub fn matches_password( + &self, + password: impl core::convert::AsRef, + ) -> bool { + match self.password() { + Ok(p) => p.as_bytes().ct_eq(password.as_ref().as_bytes()).into(), + _ => false, + } + } + /// Accept the presented password. pub fn allow(mut self) -> Result<()> { self.done = true; @@ -383,6 +402,35 @@ impl<'g, 'a> ServPasswordAuth<'g, 'a> { self.runner.resume_servauth(false) } + /// Enable or disable password authentication for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_password_auth(&mut self, enabled: bool) -> Result<()> { + let (_, pubkey) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(enabled, pubkey) + } + + /// Enable or disable public key authentication for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_pubkey_auth(&mut self, enabled: bool) -> Result<()> { + let (password, _) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(password, enabled) + } + + /// Configure which authentication methods are allowed for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn set_auth_methods(&mut self, password: bool, pubkey: bool) -> Result<()> { + self.runner.set_auth_methods(password, pubkey) + } + pub fn raw_username(&self) -> Result> { self.runner.fetch_servusername() } @@ -451,6 +499,35 @@ impl<'g, 'a> ServPubkeyAuth<'g, 'a> { self.runner.resume_servauth(false) } + /// Enable or disable password authentication for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_password_auth(&mut self, enabled: bool) -> Result<()> { + let (_, pubkey) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(enabled, pubkey) + } + + /// Enable or disable public key authentication for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_pubkey_auth(&mut self, enabled: bool) -> Result<()> { + let (password, _) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(password, enabled) + } + + /// Configure which authentication methods are allowed for subsequent attempts. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn set_auth_methods(&mut self, password: bool, pubkey: bool) -> Result<()> { + self.runner.set_auth_methods(password, pubkey) + } + pub fn raw_username(&self) -> Result> { self.runner.fetch_servusername() } @@ -482,6 +559,17 @@ impl<'g, 'a> ServFirstAuth<'g, 'a> { self.raw_username()?.as_str() } + /// Perform a constant-time comparison of the user-presented username against a passed string. + pub fn matches_username( + &self, + username: impl core::convert::AsRef, + ) -> bool { + match self.username() { + Ok(u) => u.as_bytes().ct_eq(username.as_ref().as_bytes()).into(), + _ => false, + } + } + /// Allow the user to log in. /// /// No further authentication challenges will be requested. @@ -500,6 +588,35 @@ impl<'g, 'a> ServFirstAuth<'g, 'a> { self.runner.resume_servauth(false) } + /// Enable or disable password authentication for this session. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_password_auth(&mut self, enabled: bool) -> Result<()> { + let (_, pubkey) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(enabled, pubkey) + } + + /// Enable or disable public key authentication for this session. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn enable_pubkey_auth(&mut self, enabled: bool) -> Result<()> { + let (password, _) = self.runner.get_auth_methods()?; + self.runner.set_auth_methods(password, enabled) + } + + /// Configure which authentication methods are allowed. + /// + /// # Caution + /// Enabling or disabling authentication methods based on username can + /// unintentionally enable user enumeration attacks. + pub fn set_auth_methods(&mut self, password: bool, pubkey: bool) -> Result<()> { + self.runner.set_auth_methods(password, pubkey) + } + pub fn raw_username(&self) -> Result> { self.runner.fetch_servusername() } @@ -723,6 +840,69 @@ impl Drop for ServPtyRequest<'_, '_> { } } +/// An environment variable request +/// +pub struct ServEnvironmentRequest<'g, 'a> { + runner: &'g mut Runner<'a, Server>, + num: ChanNum, + done: bool, +} + +impl<'g, 'a> ServEnvironmentRequest<'g, 'a> { + fn new(runner: &'g mut Runner<'a, Server>, num: ChanNum) -> Self { + Self { runner, num, done: false } + } + + /// Indicate that the request succeeded. + /// + /// Note that if the peer didn't request a reply, this call + /// will not do anything. + pub fn succeed(mut self) -> Result<()> { + self.done = true; + self.runner.resume_chanreq(true) + } + + /// Indicate that the request failed. + /// + /// Note that if the peer didn't request a reply, this call + /// will not do anything. + /// Does not need to be called explicitly, also occurs on drop without `accept()` + pub fn fail(mut self) -> Result<()> { + self.done = true; + self.runner.resume_chanreq(false) + } + + /// Return the associated channel number. + /// + /// This will correspond to a `ChanHandle::num()` + /// from a previous [`ServOpenSession`] event. + pub fn channel(&self) -> ChanNum { + self.num + } + + /// Retrieve the name of the environment variable (from NAME=VALUE pair). + pub fn name(&self) -> Result<&str> { + self.raw_name()?.as_str() + } + + /// Retrieve the raw name of the environment variable. + fn raw_name(&self) -> Result> { + self.runner.fetch_env_name() + } + + /// Retrieve the value of the environment variable (from NAME=VALUE pair). + pub fn value(&self) -> Result<&str> { + self.raw_value()?.as_str() + } + + /// Retrieve the raw value of the environment variable. + fn raw_value(&self) -> Result> { + self.runner.fetch_env_value() + } + + // TODO: does the app care about wantreply? +} + // Only small values should be stored inline. // Larger state is retrieved from the current packet via Runner::fetch_*() #[derive(Debug, Clone)] @@ -748,7 +928,10 @@ pub(crate) enum ServEventId { SessionPty { num: ChanNum, }, - #[allow(unused)] + Environment { + num: ChanNum, + }, + #[expect(unused)] Defunct, // TODO: // Disconnected @@ -801,6 +984,10 @@ impl ServEventId { debug_assert!(matches!(p, Some(Packet::ChannelRequest(_)))); Ok(ServEvent::SessionPty(ServPtyRequest::new(runner, num))) } + Self::Environment { num } => { + debug_assert!(matches!(p, Some(Packet::ChannelRequest(_)))); + Ok(ServEvent::SessionEnv(ServEnvironmentRequest::new(runner, num))) + } Self::Defunct => Ok(ServEvent::Defunct), } } @@ -818,6 +1005,7 @@ impl ServEventId { | Self::SessionShell { .. } | Self::SessionExec { .. } | Self::SessionSubsystem { .. } + | Self::Environment { .. } | Self::SessionPty { .. } => true, } } diff --git a/src/ident.rs b/src/ident.rs index 36610246..e9ed8445 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -1,4 +1,4 @@ -use crate::error::{self, Error, Result, TrapBug}; +use crate::error::{self, Error, Result}; pub(crate) const OUR_VERSION: &[u8] = b"SSH-2.0-Sunset-1"; @@ -164,8 +164,8 @@ impl RemoteVersion { #[rustfmt::skip] mod tests { use crate::ident; - use crate::error::{Error,TrapBug,Result}; - use crate::sunsetlog::init_test_log; + use crate::error::{Error,Result}; + // Tests as a client, allowing leading ignored lines fn test_version(v: &str, split: usize, expect: &str) -> Result { diff --git a/src/kex.rs b/src/kex.rs index 9b6fd3f5..65b9149b 100644 --- a/src/kex.rs +++ b/src/kex.rs @@ -18,13 +18,13 @@ use ml_kem::{ kem::{Decapsulate, Encapsulate, EncapsulationKey, Kem}, Ciphertext, EncodedSizeUser, KemCore, MlKem768, MlKem768Params, }; -use rand_core::{CryptoRng, OsRng, RngCore}; +use rand_core::OsRng; use sha2::Sha256; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zeroize::ZeroizeOnDrop; use crate::*; use encrypt::{Cipher, Integ, KeysRecv, KeysSend}; -use event::{CliEventId, ServEventId}; +use event::ServEventId; use ident::RemoteVersion; use namelist::{LocalNames, NameList}; use packets::{KexCookie, Packet, PubKey, Signature}; @@ -106,7 +106,7 @@ impl AlgoConfig { } /// The current state of the Kex -#[allow(clippy::enum_variant_names)] +#[expect(clippy::enum_variant_names)] #[derive(Debug)] pub(crate) enum Kex { /// No key exchange in progress @@ -346,7 +346,7 @@ impl Kex { } let kex_hash = KexHash::new::( algo_conf, - &our_cookie, + our_cookie, remote_version, &remote_kexinit.into(), )?; @@ -509,11 +509,11 @@ impl Kex { } pub fn is_strict(&self) -> bool { - match self { - Kex::KexDH { algos: Algos { strict_kex: true, .. }, .. } => true, - Kex::NewKeys { algos: Algos { strict_kex: true, .. }, .. } => true, - _ => false, - } + matches!( + self, + Kex::KexDH { algos: Algos { strict_kex: true, .. }, .. } + | Kex::NewKeys { algos: Algos { strict_kex: true, .. }, .. } + ) } pub fn handle_kexdhreply(&self) -> Result { @@ -547,6 +547,8 @@ impl Kex { } /// Send NewKeys and switch to next encryption key. + /// + /// To be called in Self::Taken state. fn send_newkeys( &mut self, output: KexOutput, @@ -1046,16 +1048,12 @@ impl KexMlkemX25519 { #[cfg(test)] mod tests { - use pretty_hex::PrettyHex; - use crate::encrypt::{self, KeyState, KeysRecv, KeysSend, SSH_PAYLOAD_START}; - use crate::error::Error; use crate::ident::RemoteVersion; use crate::kex; use crate::kex::*; - use crate::packets::{Packet, ParseContext}; + use crate::packets::Packet; use crate::sunsetlog::init_test_log; - use crate::*; use std::collections::VecDeque; // TODO: diff --git a/src/lib.rs b/src/lib.rs index 18a850a1..446e8fad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,11 +8,9 @@ #![forbid(unsafe_code)] // avoids headscratching #![deny(unused_must_use)] -// XXX unused_imports only during dev churn -#![allow(unused_imports)] - // Static allocations hit this inherently. -#[allow(clippy::large_enum_variant)] +#![allow(clippy::large_enum_variant)] + pub mod config; pub mod packets; pub mod sshnames; @@ -47,7 +45,7 @@ mod termmodes; mod traffic; use conn::DispatchEvent; -use event::{CliEventId, ServEventId}; +use event::CliEventId; // Application API pub use sshwire::TextString; diff --git a/src/namelist.rs b/src/namelist.rs index a793b246..963a40a7 100644 --- a/src/namelist.rs +++ b/src/namelist.rs @@ -14,7 +14,7 @@ use sunset_sshwire_derive::{SSHDecode, SSHEncode}; use crate::*; use heapless::Vec; -use sshwire::{BinString, SSHDecode, SSHEncode, SSHSink, SSHSource, WireResult}; +use sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireResult}; // Used for lists of: // - algorithm names @@ -24,7 +24,7 @@ use sshwire::{BinString, SSHDecode, SSHEncode, SSHSink, SSHSource, WireResult}; /// Max count of LocalNames entries /// -/// Current max is for kex, [mlkem, curve25519, curve25519@libssh, ext-info, strictkex, kexguess2] +/// Current max is for kex: (mlkem, curve25519, curve25519@libssh, ext-info, strictkex, kexguess2) pub const MAX_LOCAL_NAMES: usize = 6; static EMPTY_LOCALNAMES: LocalNames = LocalNames::new(); @@ -222,8 +222,7 @@ impl LocalNames { #[cfg(test)] mod tests { use crate::namelist::*; - use crate::sunsetlog::init_test_log; - use pretty_hex::PrettyHex; + use std::vec::Vec; #[test] diff --git a/src/packets.rs b/src/packets.rs index fa55c812..a96c9a93 100644 --- a/src/packets.rs +++ b/src/packets.rs @@ -2,7 +2,7 @@ //! //! A [`Packet`] can be encoded/decoded to the //! SSH Binary Packet Protocol using [`sshwire`]. -//! SSH packet format is described in [RFC4253](https://tools.ietf.org/html/rfc5643) SSH Transport +//! SSH packet format is described in [RFC4253](https://tools.ietf.org/html/rfc4253#section-6) SSH Transport #[allow(unused_imports)] use { @@ -15,7 +15,6 @@ use core::fmt::{Debug, Display}; #[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; -use heapless::String; use pretty_hex::PrettyHex; use sunset_sshwire_derive::*; @@ -24,9 +23,9 @@ use crate::*; use namelist::NameList; use sign::{OwnedSig, SigType}; use sshnames::*; +use sshwire::SSHEncodeEnum; use sshwire::{BinString, Blob, TextString}; use sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult}; -use sshwire::{SSHDecodeEnum, SSHEncodeEnum}; #[cfg(feature = "rsa")] use rsa::traits::PublicKeyParts; @@ -361,6 +360,16 @@ impl PubKey<'_> { }; Ok(m) } + + #[cfg(feature = "openssh-key")] + pub fn fingerprint( + &self, + hash_alg: ssh_key::HashAlg, + ) -> Result { + let ssh_key: ssh_key::PublicKey = self.try_into()?; + + Ok(ssh_key.fingerprint(hash_alg)) + } } // ssh_key::PublicKey is used for known_hosts comparisons @@ -711,6 +720,8 @@ pub enum ChannelReqType<'a> { Subsystem(Subsystem<'a>), #[sshwire(variant = "window-change")] WinChange(WinChange), + #[sshwire(variant = "env")] + Environment(Environment<'a>), #[sshwire(variant = "signal")] Signal(Signal<'a>), #[sshwire(variant = "exit-status")] @@ -725,7 +736,6 @@ pub enum ChannelReqType<'a> { // Other requests that aren't implemented at present: // auth-agent-req@openssh.com // x11-req - // env // xon-xoff #[sshwire(unknown)] Unknown(Unknown<'a>), @@ -766,6 +776,14 @@ pub struct WinChange { pub height: u32, } +/// An environment variable +#[derive(Debug, SSHEncode, SSHDecode)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] +pub struct Environment<'a> { + pub name: TextString<'a>, + pub value: TextString<'a>, +} + /// A unix signal channel request #[derive(Debug, SSHEncode, SSHDecode)] #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] @@ -823,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 @@ -864,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 { @@ -1070,11 +1088,11 @@ messagetypes![ #[cfg(test)] mod tests { use crate::packets::*; - use crate::sshnames::*; - use crate::sshwire::tests::{assert_serialize_equal, test_roundtrip}; + + use crate::packets; + use crate::sshwire::tests::test_roundtrip; use crate::sshwire::{packet_from_bytes, write_ssh}; use crate::sunsetlog::init_test_log; - use crate::{packets, sshwire}; use pretty_hex::PrettyHex; #[test] diff --git a/src/runner.rs b/src/runner.rs index 6cfcb9b1..caf22ea9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -4,21 +4,13 @@ use { log::{debug, error, info, log, trace, warn}, }; -use core::{ - hash::Hash, - mem::discriminant, - task::{Poll, Waker}, -}; - -use pretty_hex::PrettyHex; +use core::{hash::Hash, mem::discriminant, task::Waker}; -use crate::packets::{Packet, Subsystem}; use crate::*; use channel::{ChanData, ChanNum}; use channel::{CliSessionExit, CliSessionOpener}; use encrypt::KeyState; use event::{CliEvent, CliEventId, Event, ServEvent, ServEventId}; -use packets::{ChannelData, ChannelDataExt}; use traffic::{TrafIn, TrafOut}; use conn::{CliServ, Conn, DispatchEvent, Dispatched}; @@ -262,6 +254,19 @@ impl<'a> Runner<'a, server::Server> { self.traf_in.done_payload(); r } + + pub(crate) fn set_auth_methods( + &mut self, + password: bool, + pubkey: bool, + ) -> Result<()> { + self.conn.set_auth_methods(password, pubkey) + } + + pub(crate) fn get_auth_methods(&self) -> Result<(bool, bool)> { + let auth = &self.conn.server()?.auth; + Ok((auth.method_password, auth.method_pubkey)) + } } impl<'a, CS: CliServ> Runner<'a, CS> { @@ -373,7 +378,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { pub(crate) fn packet(&self) -> Result>> { if let Some((payload, _seq)) = self.traf_in.payload() { - self.conn.packet(payload).map(|p| Some(p)) + self.conn.packet(payload).map(Some) } else { Ok(None) } @@ -387,6 +392,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { if !self.is_input_ready() { return Ok(0); } + self.traf_in.input(&mut self.keys, &mut self.conn.remote_version, buf) } @@ -489,7 +495,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { let len = self.write_channel_ready(chan, dt)?; let len = match len { - Some(l) if l == 0 => return Ok(0), + Some(0) => return Ok(0), Some(l) => l, None => return Err(Error::ChannelEOF), }; @@ -525,6 +531,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { } let (len, complete) = self.traf_in.read_channel(chan.0, dt, buf); + if let Some(x) = complete { self.finished_read_channel(chan, x)?; } @@ -647,7 +654,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { dt: ChanData, waker: &Waker, ) { - self.conn.channels.from_handle_mut(ch).set_read_waker( + self.conn.channels.by_handle_mut(ch).set_read_waker( dt, CS::is_client(), waker, @@ -660,7 +667,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { dt: ChanData, waker: &Waker, ) { - self.conn.channels.from_handle_mut(ch).set_write_waker( + self.conn.channels.by_handle_mut(ch).set_write_waker( dt, CS::is_client(), waker, @@ -688,7 +695,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { self.conn.channels.term_window_change(chan.0, winch, &mut s) } else { trace!("winch as server"); - Err(Error::BadUsage {}) + Err(error::BadUsage.build()) } } @@ -765,6 +772,7 @@ impl<'a, CS: CliServ> Runner<'a, CS> { | DispatchEvent::ServEvent(ServEventId::SessionExec { .. }) | DispatchEvent::ServEvent(ServEventId::SessionSubsystem { .. }) | DispatchEvent::ServEvent(ServEventId::SessionPty { .. }) + | DispatchEvent::ServEvent(ServEventId::Environment { .. }) )); } @@ -787,6 +795,20 @@ impl<'a, CS: CliServ> Runner<'a, CS> { let p = self.conn.packet(payload)?; self.conn.channels.fetch_servcommand(&p) } + + pub(crate) fn fetch_env_name(&self) -> Result> { + Self::check_chanreq(&self.resume_event); + let (payload, _seq) = self.traf_in.payload().trap()?; + let p = self.conn.packet(payload)?; + self.conn.channels.fetch_env_name(&p) + } + + pub(crate) fn fetch_env_value(&self) -> Result> { + Self::check_chanreq(&self.resume_event); + let (payload, _seq) = self.traf_in.payload().trap()?; + let p = self.conn.packet(payload)?; + self.conn.channels.fetch_env_value(&p) + } } /// Sets a waker, waking any existing waker @@ -798,7 +820,9 @@ pub(crate) fn set_waker(store_waker: &mut Option, new_waker: &Waker) { } } - store_waker.take().map(|w| w.wake()); + if let Some(w) = store_waker.take() { + w.wake() + } *store_waker = Some(new_waker.clone()) } diff --git a/src/servauth.rs b/src/servauth.rs index d4e4cd34..a4eae5bc 100644 --- a/src/servauth.rs +++ b/src/servauth.rs @@ -6,13 +6,12 @@ use { use crate::sshnames::*; use crate::*; -use event::{CliEvent, ServEventId}; +use event::ServEventId; use kex::SessId; use packets::{AuthMethod, Packet, Userauth60, UserauthPkOk, UserauthRequest}; -use sshwire::{BinString, Blob}; use traffic::TrafSend; -use heapless::{String, Vec}; +use heapless::Vec; /// Server authentication context /// @@ -29,7 +28,6 @@ pub(crate) struct ServAuth { /// Username previously used, as an array of bytes pub username: Option>, - // TODO Add setters for methods in Runner/SSHServer creation. /// Whether to advertise password authentication and present it to the application /// /// Enabled by default @@ -53,6 +51,12 @@ impl Default for ServAuth { } impl ServAuth { + /// Configure which authentication methods are allowed + pub fn set_auth_methods(&mut self, password: bool, pubkey: bool) { + self.method_password = password; + self.method_pubkey = pubkey; + } + /// Returns an event for the app, or `DispatchEvent::None` if auth failure /// has been returned immediately. pub fn request( @@ -75,7 +79,7 @@ impl ServAuth { match Vec::from_slice(p.username.0) { Result::Ok(u) => self.username = Some(u), Result::Err(_) => { - warn!("Client tried too long username"); + warn!("Client tried too long username, {}", p.username.0.len()); return error::SSHProtoUnsupported.fail(); } } @@ -130,7 +134,12 @@ impl ServAuth { // Extract the signature separately. The message for the signature // includes the auth packet without the signature part. let (key, sig) = match &mut p.method { - AuthMethod::PubKey(m) => (&m.pubkey.0, m.sig.take()), + AuthMethod::PubKey(m) => { + let sig = m.sig.take(); + // When we have a signature, we need to set force_sig=true so that the encoded message for verification has the boolean set correctly + m.force_sig = sig.is_some(); + (&m.pubkey.0, sig) + } _ => return Err(Error::bug()), }; @@ -202,7 +211,7 @@ impl ServAuth { }; let msg = auth::AuthSigMsg::new(p.clone(), sess_id); - match sig_type.verify(key, &msg, &sig) { + match sig_type.verify(key, &msg, sig) { Ok(()) => true, Err(e) => { trace!("sig failed {e}"); diff --git a/src/sign.rs b/src/sign.rs index f68cf523..460ac7b4 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -8,8 +8,6 @@ use { log::{debug, error, info, log, trace, warn}, }; -use core::ops::Deref; - use ed25519_dalek as dalek; use ed25519_dalek::{Signer, Verifier}; use zeroize::ZeroizeOnDrop; @@ -17,12 +15,12 @@ use zeroize::ZeroizeOnDrop; use crate::*; use packets::{Ed25519PubKey, Ed25519Sig, PubKey, Signature}; use sshnames::*; -use sshwire::{BinString, Blob, SSHEncode}; - -use pretty_hex::PrettyHex; +use sshwire::{Blob, SSHEncode}; use core::mem::discriminant; +// only required for some configurations +#[allow(unused_imports)] use digest::Digest; // TODO remove once we use byupdate. @@ -234,7 +232,6 @@ pub enum KeyType { /// /// This may hold the private key part locally /// or potentially send the signing requests to an SSH agent or other entity. -// #[derive(ZeroizeOnDrop, Clone, PartialEq)] #[derive(ZeroizeOnDrop, Clone, PartialEq, Eq)] pub enum SignKey { // TODO: we could just have the 32 byte seed here to save memory, but @@ -457,12 +454,5 @@ impl TryFrom for SignKey { #[cfg(test)] pub(crate) mod tests { - - use crate::*; - use packets; - use sign::*; - use sshnames::SSH_NAME_ED25519; - use sunsetlog::init_test_log; - // TODO: tests for sign()/verify() and invalid signatures } diff --git a/src/ssh_chapoly.rs b/src/ssh_chapoly.rs index 028c6ce6..5002fddb 100644 --- a/src/ssh_chapoly.rs +++ b/src/ssh_chapoly.rs @@ -6,16 +6,12 @@ use { log::{debug, error, info, log, trace, warn}, }; -use chacha20::cipher::{ - KeyIvInit, StreamCipher, StreamCipherSeek, StreamCipherSeekCore, -}; +use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; use chacha20::ChaCha20; use digest::KeyInit; -use poly1305::universal_hash::generic_array::GenericArray; -use poly1305::universal_hash::UniversalHash; use poly1305::Poly1305; use subtle::ConstantTimeEq; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::ZeroizeOnDrop; use crate::*; use encrypt::SSH_LENGTH_SIZE; diff --git a/src/sshnames.rs b/src/sshnames.rs index 4474aa57..5826f6b4 100644 --- a/src/sshnames.rs +++ b/src/sshnames.rs @@ -82,7 +82,7 @@ pub enum ChanFail { /// SSH agent message numbers /// /// [draft-miller-ssh-agent](https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-14#section-5.1) -#[allow(non_camel_case_types)] +#[expect(non_camel_case_types)] #[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] pub enum AgentMessageNum { SSH_AGENT_FAILURE = 5, diff --git a/src/sshwire.rs b/src/sshwire.rs index bfd698ab..f79a9dc1 100644 --- a/src/sshwire.rs +++ b/src/sshwire.rs @@ -12,11 +12,9 @@ use { }; use core::convert::AsRef; -use core::fmt::{self, Debug, Display}; +use core::fmt::{Debug, Display}; use core::str::FromStr; -use digest::Output; use pretty_hex::PrettyHex; -use snafu::{prelude::*, Location}; use ascii::{AsAsciiStr, AsciiChar, AsciiStr}; @@ -126,7 +124,7 @@ pub fn packet_from_bytes<'a>(b: &'a [u8], ctx: &ParseContext) -> Result SSHSink for EncodeBytes<'a> { return Err(WireError::NoRoom); } // keep the borrow checker happy - let tmp = core::mem::replace(&mut self.target, &mut []); + let tmp = core::mem::take(&mut self.target); let t; (t, self.target) = tmp.split_at_mut(v.len()); t.copy_from_slice(v); @@ -486,6 +484,12 @@ impl SSHEncode for u32 { } } +impl SSHEncode for u64 { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + s.push(&self.to_be_bytes()) + } +} + // no length prefix impl SSHEncode for &[u8] { fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { @@ -555,6 +559,16 @@ impl<'de> SSHDecode<'de> for u32 { } } +impl<'de> SSHDecode<'de> for u64 { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + let t = s.take(core::mem::size_of::())?; + Ok(u64::from_be_bytes(t.try_into().unwrap())) + } +} + /// Decodes a SSH name string. Must be ASCII /// without control characters. RFC4251 section 6. pub fn try_as_ascii(t: &[u8]) -> WireResult<&AsciiStr> { @@ -569,7 +583,7 @@ pub fn try_as_ascii_str(t: &[u8]) -> WireResult<&str> { try_as_ascii(t).map(AsciiStr::as_str) } -impl<'de: 'a, 'a> SSHDecode<'de> for &'a str { +impl<'de> SSHDecode<'de> for &'de str { fn dec(s: &mut S) -> WireResult where S: SSHSource<'de>, @@ -580,7 +594,7 @@ impl<'de: 'a, 'a> SSHDecode<'de> for &'a str { } } -impl<'de: 'a, 'a> SSHDecode<'de> for &'de AsciiStr { +impl<'de> SSHDecode<'de> for &'de AsciiStr { fn dec(s: &mut S) -> WireResult<&'de AsciiStr> where S: SSHSource<'de>, diff --git a/src/sunsetlog.rs b/src/sunsetlog.rs index d9b12263..1e76ff2e 100644 --- a/src/sunsetlog.rs +++ b/src/sunsetlog.rs @@ -1,8 +1,6 @@ #[cfg(test)] use simplelog::{self, LevelFilter, TestLogger}; -pub use ::log::{debug, error, info, log, trace, warn}; - #[cfg(test)] pub fn init_test_log() { let conf = diff --git a/src/test.rs b/src/test.rs index 598f6840..be359595 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3,9 +3,8 @@ mod tests { use crate::error::Error; use crate::packets::*; use crate::packets::{Packet, ParseContext}; + use crate::sshwire; use crate::sshwire::BinString; - use crate::{packets, sshwire}; - use pretty_hex::PrettyHex; use simplelog::{self, LevelFilter, TestLogger}; pub fn init_log() { diff --git a/src/traffic.rs b/src/traffic.rs index 4d21d5fd..c9551fe0 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -9,13 +9,9 @@ use { use zeroize::Zeroize; use crate::channel::{ChanData, ChanNum}; -use crate::encrypt::{ - KeyState, KeysRecv, KeysSend, SSH_LENGTH_SIZE, SSH_PAYLOAD_START, -}; +use crate::encrypt::{KeyState, KeysRecv, KeysSend, SSH_PAYLOAD_START}; use crate::ident::RemoteVersion; -use crate::packets::Packet; use crate::*; -use pretty_hex::PrettyHex; // TODO: if smoltcp exposed both ends of a CircularBuffer to recv() // we could perhaps just work directly in smoltcp's provided buffer? @@ -158,9 +154,7 @@ impl<'a> TrafIn<'a> { /// Returns a reference to the decrypted payload buffer if ready, /// and the `seq` of that packet. - // TODO: only pub for testing - // pub(crate) fn payload(&mut self) -> Option<(&[u8], u32)> { - pub fn payload(&self) -> Option<(&[u8], u32)> { + pub(crate) fn payload(&self) -> Option<(&[u8], u32)> { match self.state { RxState::InPayload { len, seq } => { let payload = &self.buf[SSH_PAYLOAD_START..SSH_PAYLOAD_START + len]; @@ -445,17 +439,14 @@ impl<'a> TrafOut<'a> { } pub fn consume_output(&mut self, l: usize) { - match self.state { - TxState::Write { ref mut idx, len } => { - let wlen = (len - *idx).min(l); - *idx += wlen; + if let TxState::Write { ref mut idx, len } = self.state { + let wlen = (len - *idx).min(l); + *idx += wlen; - if *idx == len { - // all done, read the next packet - self.state = TxState::Idle - } + if *idx == len { + // all done, read the next packet + self.state = TxState::Idle } - _ => (), } } diff --git a/sshwire-derive/src/lib.rs b/sshwire-derive/src/lib.rs index 43e1da60..8345adc1 100644 --- a/sshwire-derive/src/lib.rs +++ b/sshwire-derive/src/lib.rs @@ -2,6 +2,7 @@ //! //! `SSHWIRE_DEBUG` environment variable can be set at build time //! to write generated files to the `target/` directory. +#![expect(clippy::useless_format)] use std::collections::HashSet; use std::env; diff --git a/stdasync/examples/sunsetc.rs b/stdasync/examples/sunsetc.rs index 1ead5607..05f0bbc2 100644 --- a/stdasync/examples/sunsetc.rs +++ b/stdasync/examples/sunsetc.rs @@ -188,17 +188,17 @@ struct Args { cmd: Vec, // options for compatibility with sshfs, are ignored - #[allow(unused)] + #[expect(unused)] #[argh(switch, short = 'x', hidden_help)] /// no X11 no_x11: bool, - #[allow(unused)] + #[expect(unused)] #[argh(switch, short = 'a', hidden_help)] /// no agent forwarding no_agent: bool, - #[allow(unused)] + #[expect(unused)] #[argh(switch, short = '2', hidden_help)] /// ssh version 2 version_2: bool, diff --git a/stdasync/src/agent.rs b/stdasync/src/agent.rs index 99f0005c..62f9b44a 100644 --- a/stdasync/src/agent.rs +++ b/stdasync/src/agent.rs @@ -12,12 +12,10 @@ use tokio::net::UnixStream; use sunset_sshwire_derive::*; -use crate::*; use sshwire::{ - BinString, Blob, SSHDecode, SSHEncode, SSHSink, SSHSource, TextString, - WireError, WireResult, + Blob, SSHDecode, SSHEncode, SSHSink, SSHSource, TextString, WireError, + WireResult, }; -use sshwire::{SSHDecodeEnum, SSHEncodeEnum}; use sunset::sshnames::*; use sunset::sshwire; use sunset::{AuthSigMsg, OwnedSig, PubKey, SignKey, Signature}; diff --git a/stdasync/src/cmdline_client.rs b/stdasync/src/cmdline_client.rs index 46d2830c..e7fdaf1b 100644 --- a/stdasync/src/cmdline_client.rs +++ b/stdasync/src/cmdline_client.rs @@ -1,19 +1,16 @@ use embassy_futures::select::Either; -use futures::pin_mut; #[allow(unused_imports)] use log::{debug, error, info, log, trace, warn}; use sunset::event::CliEvent; -use sunset::packets::WinChange; use core::fmt::Debug; -use core::str::FromStr; use std::process::ExitCode; -use sunset::{sshnames, AuthSigMsg, OwnedSig, Pty, SignKey}; -use sunset::{Error, Result, Runner, SessionCommand}; +use sunset::{sshnames, Pty, SignKey}; +use sunset::{Error, Result, SessionCommand}; use sunset_async::*; -use embassy_sync::channel::{Channel, Receiver, Sender}; +use embassy_sync::channel::Channel; use embassy_sync::signal::Signal; use embedded_io_async::{Read as _, Write as _}; use std::collections::VecDeque; @@ -22,9 +19,6 @@ use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::signal::unix::{signal, SignalKind}; -use futures::FutureExt; -use futures::{future::Fuse, select_biased}; - use crate::pty::win_size; use crate::AgentClient; use crate::*; @@ -325,7 +319,10 @@ impl CmdlineClient { } } CliEvent::Banner(b) => { - println!("Banner from server:\n{}", b.banner()?) + println!( + "Banner from server:\n{}", + EscapeBanner(b.banner()?) + ) } CliEvent::Defunct => { trace!("break defunct"); @@ -369,6 +366,22 @@ impl CmdlineClient { } } +/// Disallows ascii control characters, allows newlines. +struct EscapeBanner<'a>(&'a str); + +impl core::fmt::Display for EscapeBanner<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for c in self.0.chars() { + if c.is_control() && c != '\n' { + f.write_str(".")?; + } else { + write!(f, "{}", c)?; + } + } + Ok(()) + } +} + fn set_pty_guard(pty_guard: &mut Option) { match raw_pty() { Ok(p) => *pty_guard = Some(p), diff --git a/stdasync/src/fdio.rs b/stdasync/src/fdio.rs index 5fbbc8f7..8d645e87 100644 --- a/stdasync/src/fdio.rs +++ b/stdasync/src/fdio.rs @@ -8,7 +8,7 @@ use tokio::io::{AsyncRead, AsyncWrite, Interest, ReadBuf}; use std::fs::File; use std::io::Error as IoError; use std::io::{Read, Write}; -use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +use std::os::fd::{AsRawFd, FromRawFd}; use core::pin::Pin; use core::task::{Context, Poll}; diff --git a/stdasync/src/knownhosts.rs b/stdasync/src/knownhosts.rs index b9559c82..01d30490 100644 --- a/stdasync/src/knownhosts.rs +++ b/stdasync/src/knownhosts.rs @@ -1,12 +1,11 @@ #[allow(unused_imports)] use log::{debug, error, info, log, trace, warn}; -use std::fs::{File, OpenOptions}; +use std::fs::File; use std::io; use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; -use crate::*; use sunset::packets::PubKey; type OpenSSHKey = ssh_key::PublicKey; @@ -45,7 +44,7 @@ where const USER_KNOWN_HOSTS: &str = ".ssh/known_hosts"; fn user_known_hosts() -> Result { - // home_dir() works fine on linux. + // home_dir() was undeprecated in 1.87 #[allow(deprecated)] let p = std::env::home_dir().ok_or_else(|| KnownHostsError::Other { msg: "Failed getting home directory".into(), @@ -118,20 +117,18 @@ pub fn check_known_hosts_file( if pubk.algorithm() != known_key.algorithm() { debug!("Line {line}, Ignoring other-format existing key {known_key:?}") + } else if pubk.key_data() == known_key.key_data() { + debug!("Line {line}, found matching key"); + return Ok(()); } else { - if pubk.key_data() == known_key.key_data() { - debug!("Line {line}, found matching key"); - return Ok(()); - } else { - let fp = known_key.fingerprint(Default::default()); - println!("\nHost key mismatch for {match_host} in ~/.ssh/known_hosts line {line}\n\ - Existing key has fingerprint {fp}\n"); - return Err(KnownHostsError::Mismatch { - path: p.to_path_buf(), - line, - existing: known_key, - }); - } + let fp = known_key.fingerprint(Default::default()); + println!("\nHost key mismatch for {match_host} in ~/.ssh/known_hosts line {line}\n\ + Existing key has fingerprint {fp}\n"); + return Err(KnownHostsError::Mismatch { + path: p.to_path_buf(), + line, + existing: known_key, + }); } } diff --git a/stdasync/src/lib.rs b/stdasync/src/lib.rs index 20986076..f80ededc 100644 --- a/stdasync/src/lib.rs +++ b/stdasync/src/lib.rs @@ -6,7 +6,6 @@ //! [`AgentClient`] can communicate with a separate `ssh-agent` for signing. //! //! `sunsetc` example is usable as a day-to-day SSH client on Linux. -#![allow(unused_imports)] // avoid mysterious missing awaits #![deny(unused_must_use)] @@ -27,4 +26,3 @@ pub use cmdline_client::CmdlineClient; pub use agent::AgentClient; // for sshwire derive -use sunset::sshwire; diff --git a/stdasync/src/pty.rs b/stdasync/src/pty.rs index 2ccb6f96..769e4419 100644 --- a/stdasync/src/pty.rs +++ b/stdasync/src/pty.rs @@ -10,7 +10,7 @@ use nix::sys::termios::Termios; use sunset::config::*; use sunset::packets::WinChange; -use sunset::{Pty, Result, Runner}; +use sunset::{Pty, Result}; /// Returns the size of the current terminal pub fn win_size() -> Result { diff --git a/testing/ci.sh b/testing/ci.sh index 733413f7..49b56766 100755 --- a/testing/ci.sh +++ b/testing/ci.sh @@ -42,7 +42,8 @@ cargo test --doc cd stdasync # only test lib since some examples are broken cargo test --lib -cargo build --example sunsetc +# test backtrace feature too +cargo build --example sunsetc --features sunset/backtrace # with/without release to test debug_assertions cargo build --release --example sunsetc ) @@ -73,6 +74,15 @@ 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 --release +cargo test --release +cargo bloat --release -n 100 | tee "$OUT/sftp-std-bloat.txt" +cargo bloat --release --crates | tee "$OUT/sftp-std-bloat-crates.txt" +) +size ./target/release/sunset-demo-sftp-std | tee "$OUT/sftp-std-size.txt" + ( cd fuzz cargo check --features nofuzz --profile fuzz