From de638df23e842497e0668964aac72fe98c9cb9af Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Wed, 4 Mar 2026 10:09:48 +1100 Subject: [PATCH 01/16] [skip ci] Adding proto, dependencies and required changes First commit summarizing the implementation in the draft pull request contained on the dev/sftp-start branch. I am trying to keep the commit number low and focused on different parts of the implementation. - sshwire-derive/src/lib.rs: Modified enconde_enum to allow encoding of enums discriminants - Added the full proto and sftpsource definitions and Cargo.toml for the sftp crate. - sftp/src/lib.rs: Will experience many changes as the functionality is implemented, but for now it just re-exports the proto and sftpsource modules. --- Cargo.lock | 48 +- Cargo.toml | 33 +- sftp/Cargo.toml | 26 + sftp/src/lib.rs | 128 +++++ sftp/src/proto.rs | 1120 +++++++++++++++++++++++++++++++++++++ sftp/src/sftpsource.rs | 239 ++++++++ sshwire-derive/src/lib.rs | 7 +- 7 files changed, 1571 insertions(+), 30 deletions(-) create mode 100644 sftp/Cargo.toml create mode 100644 sftp/src/lib.rs create mode 100644 sftp/src/proto.rs create mode 100644 sftp/src/sftpsource.rs diff --git a/Cargo.lock b/Cargo.lock index b347b125..8874a498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,9 +795,9 @@ dependencies = [ [[package]] name = "embassy-futures" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f878075b9794c1e4ac788c95b728f26aa6366d32eeb10c7051389f898f7d067" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" [[package]] name = "embassy-hal-internal" @@ -924,15 +924,15 @@ dependencies = [ [[package]] name = "embassy-sync" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef1a8a1ea892f9b656de0295532ac5d8067e9830d49ec75076291fd6066b136" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" dependencies = [ "cfg-if", "critical-section", "embedded-io-async", + "futures-core", "futures-sink", - "futures-util", "heapless", ] @@ -1792,11 +1792,12 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ - "num_enum_derive 0.7.3", + "num_enum_derive 0.7.5", + "rustversion", ] [[package]] @@ -1812,9 +1813,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", @@ -1991,7 +1992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406" dependencies = [ "arrayvec", - "num_enum 0.7.3", + "num_enum 0.7.5", "paste", ] @@ -2740,7 +2741,7 @@ name = "sunset-async" version = "0.4.0" dependencies = [ "embassy-futures", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embedded-io-async", "log", "portable-atomic", @@ -2757,7 +2758,7 @@ dependencies = [ "embassy-futures", "embassy-net", "embassy-net-driver", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embedded-io-async", "heapless", @@ -2787,7 +2788,7 @@ dependencies = [ "embassy-net", "embassy-net-wiznet", "embassy-rp", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embassy-usb", "embassy-usb-driver", @@ -2822,7 +2823,7 @@ dependencies = [ "embassy-futures", "embassy-net", "embassy-net-tuntap", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embassy-time", "embedded-io-async", "env_logger", @@ -2850,6 +2851,21 @@ dependencies = [ "sunset-sshwire-derive", ] +[[package]] +name = "sunset-sftp" +version = "0.1.2" +dependencies = [ + "embassy-futures", + "embassy-sync 0.7.2", + "embedded-io-async", + "log", + "num_enum 0.7.5", + "paste", + "sunset", + "sunset-async", + "sunset-sshwire-derive", +] + [[package]] name = "sunset-sshwire-derive" version = "0.2.1" @@ -2865,7 +2881,7 @@ dependencies = [ "argh", "critical-section", "embassy-futures", - "embassy-sync 0.7.0", + "embassy-sync 0.7.2", "embedded-io-adapters", "embedded-io-async", "futures", diff --git a/Cargo.toml b/Cargo.toml index 61ffdc26..b5635e3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ rust-version = "1.87" [workspace] members = [ "demo/picow", - "demo/std", "fuzz", + "demo/std", + "fuzz", "stdasync", + "sftp", # workspace.dependencies paths are automatic ] @@ -39,7 +41,9 @@ ascii = { version = "1.0", default-features = false } arbitrary = { workspace = true, optional = true } getrandom = "0.2" -rand_core = { version = "0.6", default-features = false, features = ["getrandom"]} +rand_core = { version = "0.6", default-features = false, features = [ + "getrandom", +] } ctr = { version = "0.9", features = ["zeroize"] } aes = { version = "0.8", features = ["zeroize"] } @@ -53,14 +57,27 @@ zeroize = { version = "1", default-features = false, features = ["derive"] } cipher = { version = "0.4", features = ["zeroize"] } subtle = { version = "2.4", default-features = false } # ed25519/x25519 -ed25519-dalek = { version = "2.1", default-features = false, features = ["zeroize", "rand_core"] } -x25519-dalek = { version = "2.0", default-features = false, features = ["zeroize"] } -curve25519-dalek = { version = "4.1", default-features = false, features = ["zeroize"] } -ml-kem = { version = "0.2.1", default-features = false, features = ["zeroize"], optional = true } +ed25519-dalek = { version = "2.1", default-features = false, features = [ + "zeroize", + "rand_core", +] } +x25519-dalek = { version = "2.0", default-features = false, features = [ + "zeroize", +] } +curve25519-dalek = { version = "4.1", default-features = false, features = [ + "zeroize", +] } +ml-kem = { version = "0.2.1", default-features = false, features = [ + "zeroize", +], optional = true } # p521 = { version = "0.13.2", default-features = false, features = ["ecdh", "ecdsa"] } -rsa = { version = "0.9", default-features = false, optional = true, features = ["sha2"] } +rsa = { version = "0.9", default-features = false, optional = true, features = [ + "sha2", +] } # TODO: getrandom feature is a workaround for missing ssh-key dependency with rsa. fixed in pending 0.6 -ssh-key = { version = "0.6", default-features = false, optional = true, features = ["getrandom"] } +ssh-key = { version = "0.6", default-features = false, optional = true, features = [ + "getrandom", +] } embedded-io = { version = "0.6", optional = true } diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml new file mode 100644 index 00000000..23e4511a --- /dev/null +++ b/sftp/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sunset-sftp" +version = "0.1.2" +edition = "2024" + +[features] +default = [] +# long paths support, which allows paths up to 4096 bytes, by default paths are limited to 256 bytes +long-paths-4096 = [] +long-paths-1024 = [] + +# Standard library support - enables std helpers +std = [] + +[dependencies] +sunset = { path = "../" } +sunset-async = { path = "../async" } +sunset-sshwire-derive = { path = "../sshwire-derive" } + + +embedded-io-async = "0.6" +num_enum = { version = "0.7.4", default-features = false } +paste = "1.0" +log = "0.4" +embassy-sync = "0.7.2" +embassy-futures = "0.1.2" diff --git a/sftp/src/lib.rs b/sftp/src/lib.rs new file mode 100644 index 00000000..24f7b363 --- /dev/null +++ b/sftp/src/lib.rs @@ -0,0 +1,128 @@ +//! 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 +//! +//! - [ ] [SFTP Protocol Initialization](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-4) (Only SFTP V3 supported) +//! - [ ] [Canonicalizing the Server-Side Path Name](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.11) support +//! - [ ] [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) +//! - [ ] 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), +//! - [ ] File [stats](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) +//! +//! ## Minimal features for convenient usability +//! +//! - [ ] [Removing files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.5) +//! - [ ] [Renaming files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.5) +//! - [ ] [Creating directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.6) +//! - [ ] [Removing directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.6) +//! +//! ## Extended features +//! +//! - [ ] [Append, create and truncate files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +//! files +//! - [ ] [Reading](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) +//! files attributes +//! - [ ] [Setting](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.9) files attributes +//! - [ ] [Dealing with Symbolic links](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.10) +//! - [ ] [Vendor Specific](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-8) +//! request and responses + +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +// mod opaquefilehandle; +mod proto; +// mod sftperror; +// mod sftphandler; +// mod sftpserver; +// mod sftpsink; +mod sftpsource; + +// Main calling point for the library provided that the user implements +// a [`server::SftpServer`]. +// +// Please see basic usage at `../demo/sftd/std` +// pub use sftphandler::SftpHandler; + +/// Source of SFTP packets +/// +/// Used to decode SFTP packets from a byte slice +pub use sftpsource::SftpSource; + +// /// Structures and types used to add the details for the target system +// /// Related to the implementation of the [`server::SftpServer`], which +// /// is meant to be instantiated by the user and passed to [`SftpHandler`] +// /// and has the task of executing client requests in the underlying system +// pub mod server { + +// pub use crate::sftpserver::DirReply; +// pub use crate::sftpserver::ReadReply; +// pub use crate::sftpserver::ReadStatus; +// pub use crate::sftpserver::SftpOpResult; +// pub use crate::sftpserver::SftpServer; +// /// Helpers to reduce error prone tasks and hide some details that +// /// add complexity when implementing an [`SftpServer`] +// pub mod helpers { +// pub use crate::sftpserver::helpers::*; + +// #[cfg(feature = "std")] +// pub use crate::sftpserver::DirEntriesCollection; +// #[cfg(feature = "std")] +// pub use crate::sftpserver::get_file_attrs; +// } +// pub use crate::sftpsink::SftpSink; +// pub use sunset::sshwire::SSHEncode; + +// pub use crate::proto::MAX_REQUEST_LEN; +// } + +/// Handles and helpers used by the [`sftpserver::SftpServer`] trait implementer +// pub mod handles { +// pub use crate::opaquefilehandle::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/proto.rs b/sftp/src/proto.rs new file mode 100644 index 00000000..7b1857b8 --- /dev/null +++ b/sftp/src/proto.rs @@ -0,0 +1,1120 @@ +use crate::sftpsource::SftpSource; + +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 handle maximum length +pub const _SSH_FXP_HANDLE_MAX_LEN: u32 = 256; + +/// The maximum size for full paths is only limited by the u32 where ssh strings lengths are contained. This causes that different platforms use different maximum path lengths. +/// We need to make a choice in this implementation. Since it is targeting embedded devices I am going to set it short, since influence the length of the [[requestHolder]] that needs to be allocated +/// to compose fragmented requests. +#[cfg(not(any(feature = "long-paths-4096", feature = "long-paths-1024")))] +pub const MAX_PATH_LEN: usize = 256; +#[cfg(feature = "long-paths-1024")] +pub const MAX_PATH_LEN: usize = 1024; // PATH_MAX for macOS +#[cfg(feature = "long-paths-4096")] +pub const MAX_PATH_LEN: usize = 4096; // Linux glibc PATH_MAX is typically 4096 bytes + +/// Maximum request size, considering [[MAX_PATH_LEN]] but not counting the data payload. +/// At this moment in time, the longest request is `ssh_fxp_open` +pub const MAX_REQUEST_LEN: usize = 4 + MAX_PATH_LEN // Filename string + + 4 // PFlags (u32) + + 32; // Attrs (Max 32Bytes not counting extensions) + +/// Considering the definition in [Section 7](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +/// for `SSH_FXP_READDIR` +/// +/// (4 + 256) bytes for filename, (4 + 0) bytes for empty long filename and 72 bytes for the attributes ( 32/4*7 + 64/4 * 1 = 72) +pub const MAX_NAME_ENTRY_SIZE: usize = 4 + MAX_PATH_LEN + 4 + 72; + +// TODO is utf8 enough, or does this need to be an opaque binstring? +/// See [SSH_FXP_NAME in Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Filename<'a>(TextString<'a>); + +impl<'a> From<&'a str> for Filename<'a> { + fn from(s: &'a str) -> Self { + Filename(TextString(s.as_bytes())) + } +} + +// TODO standardize the encoding of filenames as str +impl<'a> Filename<'a> { + /// + pub fn as_str(&self) -> Result<&'a str, WireError> { + core::str::from_utf8(self.0 .0).map_err(|_| WireError::BadString) + } +} + +/// An opaque handle that is used by the server to identify an open +/// file or folder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, SSHEncode, SSHDecode)] +pub struct FileHandle<'a>(pub BinString<'a>); + +// ========================== Initialization =========================== + +/// The reference implementation we are working on is 3, this is, https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02 +pub const SFTP_VERSION: u32 = 3; + +/// The SFTP version of the client +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct InitVersionClient { + // No ReqId for SSH_FXP_INIT + pub version: u32, + // TODO variable number of ExtPair +} + +/// The lowers SFTP version from the client and the server +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct InitVersionLowest { + // No ReqId for SSH_FXP_VERSION + pub version: u32, + // TODO variable number of ExtPair +} + +// ============================= Requests ============================== + +/// Used for `ssh_fxp_open` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Open<'a> { + /// The relative or absolute path of the file to be open + pub filename: Filename<'a>, + /// File [permissions flags](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) + pub pflags: PFlags, + /// Initial attributes for the file + pub attrs: Attrs, +} + +/// Flags for Open RequestFor more information see [Opening, creating and closing files](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +#[derive(Debug, FromPrimitive, PartialEq)] +#[repr(u32)] +#[allow(non_camel_case_types, missing_docs)] +pub enum PFlags { + //#[sshwire(variant = "ssh_fx_read")] + SSH_FXF_READ = 0x00000001, + //#[sshwire(variant = "ssh_fx_write")] + SSH_FXF_WRITE = 0x00000002, + //#[sshwire(variant = "ssh_fx_append")] + SSH_FXF_APPEND = 0x00000004, + //#[sshwire(variant = "ssh_fx_creat")] + SSH_FXF_CREAT = 0x00000008, + //#[sshwire(variant = "ssh_fx_trunk")] + SSH_FXF_TRUNC = 0x00000010, + //#[sshwire(variant = "ssh_fx_excl")] + SSH_FXF_EXCL = 0x00000020, + //#[sshwire(unknown)] + #[num_enum(catch_all)] + Multiple(u32), +} + +impl<'de> SSHDecode<'de> for PFlags { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(PFlags::from(u32::dec(s)?)) + } +} + +// This is prone to errors if we update PFlags enum: Unlikely +impl From<&PFlags> for u32 { + fn from(value: &PFlags) -> Self { + match value { + PFlags::SSH_FXF_READ => 0x00000001, + PFlags::SSH_FXF_WRITE => 0x00000002, + PFlags::SSH_FXF_APPEND => 0x00000004, + PFlags::SSH_FXF_CREAT => 0x00000008, + PFlags::SSH_FXF_TRUNC => 0x00000010, + PFlags::SSH_FXF_EXCL => 0x00000020, + PFlags::Multiple(value) => *value, + } + } +} +// TODO: Implement an SSHEncode attribute for enums to encode them in a given numeric format +impl SSHEncode for PFlags { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let numeric_value: u32 = self.into(); + numeric_value.enc(s) + } +} + +/// Used for `ssh_fxp_open` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct OpenDir<'a> { + /// The relative or absolute path of the directory to be open + pub dirname: Filename<'a>, +} + +/// Used for `ssh_fxp_close` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Close<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder to be closed. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_read` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Read<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, + /// The offset for the read operation + pub offset: u64, + /// The number of bytes to be retrieved + pub len: u32, +} + +/// Used for `ssh_fxp_readdir` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct ReadDir<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_write` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Write<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, + /// The offset for the read operation + pub offset: u64, + + /// The data length to be written. Given that it can be arbitrary long, the data is not decoded + /// Instead the data_len is used in [[SftpHandler.Process]] to generate SftpServer.Write calls + pub data_len: u32, +} + +/// Used for `ssh_fxp_lstat` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8). +/// LSTAT does not follow symbolic links +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct LStat<'a> { + /// The path of the element which stats are to be retrieved + pub file_path: TextString<'a>, +} + +/// Used for `ssh_fxp_lstat` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8). +/// STAT does follow symbolic links +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Stat<'a> { + /// The path of the element which stats are to be retrieved + pub file_path: TextString<'a>, +} + +// ============================= Responses ============================= + +/// Used for `ssh_fxp_realpath` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.11). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct PathInfo<'a> { + /// The path + pub path: TextString<'a>, +} + +/// Used for `ssh_fxp_status` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Status<'a> { + /// See [`StatusCode`] for possible codes + pub code: StatusCode, + /// An extra message + pub message: TextString<'a>, + /// A language tag as defined by [Tags for the Identification of Languages](https://datatracker.ietf.org/doc/html/rfc1766) + pub lang: TextString<'a>, +} + +/// Used for `ssh_fxp_handle` [response](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, Clone, Copy, PartialEq, Eq, SSHEncode, SSHDecode)] +pub struct Handle<'a> { + /// An opaque handle that is used by the server to identify an open + /// file or folder. + pub handle: FileHandle<'a>, +} + +/// Used for `ssh_fxp_data` [responses](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct Data<'a> { + /// raw data + pub data: BinString<'a>, +} + +/// This is the encoded length for the [`Data`] Sftp Response. +/// +/// This considers the Packet type (1), the request ID (4), and the data string +/// length (4) +/// +/// - It excludes explicitly length field for the SftpPacket +/// - It excludes explicitly length of the data string content +/// +/// It is defined a single source of truth for what is the length for the +/// encoded [`SftpPacket::Data`] variant +/// +/// See [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +pub(crate) const ENCODED_SSH_FXP_DATA_MIN_LENGTH: u32 = 1 + 4 + 4; + +/// Struct to hold `SSH_FXP_NAME` response. +/// See [SSH_FXP_NAME in Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, SSHEncode, SSHDecode)] +pub struct NameEntry<'a> { + /// Is a file name being returned + pub filename: Filename<'a>, + /// longname is an undefined text line like "ls -l", + /// SHOULD NOT be used. + pub _longname: Filename<'a>, + /// Attributes for the file entry + /// + /// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) + /// for more information. + pub attrs: Attrs, +} + +/// This is the encoded length for the Name Sftp Response. +/// +/// This considers the Packet type (1), the Request Id (4) and +/// count of [`NameEntry`] that will follow +/// +/// It excludes the length of [`NameEntry`] explicitly +/// +/// It is defined a single source of truth for what is the length for the +/// encoded [`SftpPacket::Name`] variant +/// +/// See [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +pub(crate) const ENCODED_BASE_NAME_SFTP_PACKET_LENGTH: u32 = 9; + +// TODO Will a Vector be an issue for no_std? +// Maybe we should migrate this to heapless::Vec and let the user decide +// the number of elements via features flags? +/// This is the first part of the `SSH_FXP_NAME` response. It includes +/// only the count of [`NameEntry`] items that follow this Name +/// +/// After encoding or decoding [`Name`], [`NameEntry`] must be encoded or +/// decoded `count` times +/// A collection of [`NameEntry`] used for [ssh_fxp_name responses](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7). +#[derive(Debug)] +// pub struct Name<'a>(pub Vec>); +pub struct Name { + /// Number of [`NameEntry`] items that follow this Name + pub count: u32, +} + +impl<'de> SSHDecode<'de> for Name { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + let count = u32::dec(s)? as u32; + + // let mut names = Vec::with_capacity(count); + + // for _ in 0..count { + // names.push(NameEntry::dec(s)?); + // } + + Ok(Name { count }) + } +} + +impl SSHEncode for Name { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + self.count.enc(s) + // (self.0.len() as u32).enc(s)?; + + // for element in self.0.iter() { + // element.enc(s)?; + // } + // Ok(()) + } +} + +// Requests/Responses data types + +#[derive(Debug, SSHEncode, SSHDecode, Clone, Copy, PartialEq, Eq)] +pub struct ReqId(pub u32); + +/// For more information see [Responses from the Server to the Client](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-7) +#[derive(Debug, FromPrimitive)] +#[repr(u32)] +#[allow(non_camel_case_types, missing_docs)] +pub enum StatusCode { + // #[sshwire(variant = "ssh_fx_ok")] + SSH_FX_OK = 0, + // #[sshwire(variant = "ssh_fx_eof")] + SSH_FX_EOF = 1, + // #[sshwire(variant = "ssh_fx_no_such_file")] + SSH_FX_NO_SUCH_FILE = 2, + // #[sshwire(variant = "ssh_fx_permission_denied")] + SSH_FX_PERMISSION_DENIED = 3, + // #[sshwire(variant = "ssh_fx_failure")] + SSH_FX_FAILURE = 4, + // #[sshwire(variant = "ssh_fx_bad_message")] + SSH_FX_BAD_MESSAGE = 5, + // #[sshwire(variant = "ssh_fx_no_connection")] + SSH_FX_NO_CONNECTION = 6, + // #[sshwire(variant = "ssh_fx_connection_lost")] + SSH_FX_CONNECTION_LOST = 7, + // #[sshwire(variant = "ssh_fx_unsupported")] + SSH_FX_OP_UNSUPPORTED = 8, + // #[sshwire(unknown)] + #[num_enum(catch_all)] + Other(u32), +} + +impl<'de> SSHDecode<'de> for StatusCode { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + Ok(StatusCode::from(u32::dec(s)?)) + } +} + +// This is prone to errors if we update StatusCode enum: Unlikely to change +impl From<&StatusCode> for u32 { + fn from(value: &StatusCode) -> Self { + match value { + StatusCode::SSH_FX_OK => 0, + StatusCode::SSH_FX_EOF => 1, + StatusCode::SSH_FX_NO_SUCH_FILE => 2, + StatusCode::SSH_FX_PERMISSION_DENIED => 3, + StatusCode::SSH_FX_FAILURE => 4, + StatusCode::SSH_FX_BAD_MESSAGE => 5, + StatusCode::SSH_FX_NO_CONNECTION => 6, + StatusCode::SSH_FX_CONNECTION_LOST => 7, + StatusCode::SSH_FX_OP_UNSUPPORTED => 8, + StatusCode::Other(value) => *value, + } + } +} +// TODO: Implement an SSHEncode attribute for enums to encode them in a given numeric format +impl SSHEncode for StatusCode { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let numeric_value: u32 = self.into(); + numeric_value.enc(s) + } +} + +// TODO: Implement extensions. Low in priority +/// Provided to provide a mechanism to implement extensions +// #[derive(Debug, SSHEncode, SSHDecode)] +// pub struct ExtPair<'a> { +// pub name: &'a str, +// pub data: BinString<'a>, +// } + +/// Files attributes to describe Files as SFTP v3 specification +/// +/// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) +/// for more information. +#[allow(missing_docs)] +#[derive(Debug, Default, PartialEq)] +pub struct Attrs { + pub size: Option, + pub uid: Option, + pub gid: Option, + pub permissions: Option, + pub atime: Option, + pub mtime: Option, + pub ext_count: Option, + // TODO extensions +} + +/// For more information see [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) +#[repr(u32)] +#[allow(non_camel_case_types)] +pub enum AttrsFlags { + SSH_FILEXFER_ATTR_SIZE = 0x01, + SSH_FILEXFER_ATTR_UIDGID = 0x02, + SSH_FILEXFER_ATTR_PERMISSIONS = 0x04, + SSH_FILEXFER_ATTR_ACMODTIME = 0x08, + SSH_FILEXFER_ATTR_EXTENDED = 0x80000000, +} +impl core::ops::AddAssign for u32 { + fn add_assign(&mut self, other: AttrsFlags) { + *self |= other as u32; + } +} + +impl core::ops::BitAnd for u32 { + type Output = u32; + + fn bitand(self, rhs: AttrsFlags) -> Self::Output { + self & rhs as u32 + } +} + +impl Attrs { + /// Obtains the flags for the values stored in the [`Attrs`] struct. + /// + /// See [File Attributes](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#autoid-5) + /// for more information. + pub fn flags(&self) -> u32 { + let mut flags: u32 = 0; + if self.size.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_SIZE + } + if self.uid.is_some() || self.gid.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_UIDGID + } + if self.permissions.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_PERMISSIONS + } + if self.atime.is_some() || self.mtime.is_some() { + flags += AttrsFlags::SSH_FILEXFER_ATTR_ACMODTIME + } + // TODO Implement extensions + // if self.ext_count.is_some() { + // flags += AttrsFlags::SSH_FILEXFER_ATTR_EXTENDED + // } + + flags + } +} + +impl SSHEncode for Attrs { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + self.flags().enc(s)?; + + // IMPORTANT: Order matters in the encoding/decoding since it will be interpreted together with the flags + if let Some(value) = self.size.as_ref() { + value.enc(s)? + } + if let Some(value) = self.uid.as_ref() { + value.enc(s)? + } + if let Some(value) = self.gid.as_ref() { + value.enc(s)? + } + if let Some(value) = self.permissions.as_ref() { + value.enc(s)? + } + if let Some(value) = self.atime.as_ref() { + value.enc(s)? + } + if let Some(value) = self.mtime.as_ref() { + value.enc(s)? + } + // TODO Implement extensions + // if let Some(value) = self.ext_count.as_ref() { value.enc(s)? } + + Ok(()) + } +} + +impl<'de> SSHDecode<'de> for Attrs { + fn dec(s: &mut S) -> WireResult + where + S: SSHSource<'de>, + { + let mut attrs = Attrs::default(); + let flags = u32::dec(s)? as u32; + if flags & AttrsFlags::SSH_FILEXFER_ATTR_SIZE != 0 { + attrs.size = Some(u64::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_UIDGID != 0 { + attrs.uid = Some(u32::dec(s)?); + attrs.gid = Some(u32::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_PERMISSIONS != 0 { + attrs.permissions = Some(u32::dec(s)?); + } + if flags & AttrsFlags::SSH_FILEXFER_ATTR_ACMODTIME != 0 { + attrs.atime = Some(u32::dec(s)?); + attrs.mtime = Some(u32::dec(s)?); + } + // TODO Implement extensions + // if flags & AttrsFlags::SSH_FILEXFER_ATTR_EXTENDED != 0{ + + Ok(attrs) + } +} + +macro_rules! sftpmessages { + ( + init: { + $( ( $init_message_num:tt, + $init_packet_variant:ident, + $init_packet_type:ty, + $init_ssh_fxp_name:literal + ), + )* + }, + request: { + $( ( $request_message_num:tt, + $request_packet_variant:ident, + $request_packet_type:ty, + $request_ssh_fxp_name:literal + ), + )* + }, + response: { + $( ( $response_message_num:tt, + $response_packet_variant:ident, + $response_packet_type:ty, + $response_ssh_fxp_name:literal + ), + )* + }, + ) => { + paste! { + /// Represent a subset of the SFTP packet types defined by draft-ietf-secsh-filexfer-02 + #[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, 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 + + } + } + + } + + } //paste + + impl SftpNum { + fn is_init(&self) -> bool { + (1..=1).contains(&(u8::from(self.clone()))) + } + + pub(crate) fn is_request(&self) -> bool { + // TODO SSH_FXP_EXTENDED + (3..=20).contains(&(u8::from(self.clone()))) + } + + fn is_response(&self) -> bool { + // TODO SSH_FXP_EXTENDED_REPLY + (100..=105).contains(&(u8::from(self.clone()))) + ||(2..=2).contains(&(u8::from(self.clone()))) + } + } + + + /// Top level SSH packet enum + /// + /// It helps identifying the SFTP Packet type and handling it accordingly + /// This is done using the SFTP field type + #[derive(Debug)] + pub enum SftpPacket<'a> { + $( + #[doc = concat!("Initialization packet: ", $init_ssh_fxp_name)] + $init_packet_variant($init_packet_type), + )* + $( + #[doc = concat!("Request packet: ", $request_ssh_fxp_name)] + $request_packet_variant(ReqId, $request_packet_type), + )* + $( + #[doc = concat!("Response packet: ", $response_ssh_fxp_name)] + $response_packet_variant(ReqId, $response_packet_type), + )* + + } + + + impl SSHEncode for SftpPacket<'_> { + fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> { + let t = u8::from(self.sftp_num()); + t.enc(s)?; + match self { + // eg + // SftpPacket::KexInit(p) => { + // ... + $( + SftpPacket::$init_packet_variant(p) => { + p.enc(s)? + } + )* + $( + SftpPacket::$request_packet_variant(id, p) => { + id.enc(s)?; + p.enc(s)? + } + )* + $( + SftpPacket::$response_packet_variant(id, p) => { + id.enc(s)?; + p.enc(s)? + } + )* + }; + Ok(()) + } + } + + paste!{ + + + impl<'a: '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(_,_) => { + + 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 + +} // sftpmessages macro + +sftpmessages! [ + + init:{ + (1, Init, InitVersionClient, "ssh_fxp_init"), + (2, Version, InitVersionLowest, "ssh_fxp_version"), + }, + + request: { + (3, Open, Open<'a>, "ssh_fxp_open"), + (4, Close, Close<'a>, "ssh_fxp_close"), + (5, Read, Read<'a>, "ssh_fxp_read"), + (6, Write, Write<'a>, "ssh_fxp_write"), + (7, LStat, LStat<'a>, "ssh_fxp_lstat"), + (11, OpenDir, OpenDir<'a>, "ssh_fxp_opendir"), + (12, ReadDir, ReadDir<'a>, "ssh_fxp_readdir"), + (16, PathInfo, PathInfo<'a>, "ssh_fxp_realpath"), + (17, Stat, Stat<'a>, "ssh_fxp_stat"), + // When adding requests, review MAX_REQUEST_LEN in order to adjust its value + }, + + response: { + (101, Status, Status<'a>, "ssh_fxp_status"), + (102, Handle, Handle<'a>, "ssh_fxp_handle"), + (103, Data, Data<'a>, "ssh_fxp_data"), + (104, Name, Name, "ssh_fxp_name"), + (105, Attrs, Attrs, "ssh_fxp_attrs"), + }, +]; + +#[cfg(test)] +mod proto_tests { + use super::*; + use crate::server::SftpSink; + + // TODO: There are always more test that can be done + + #[cfg(test)] + extern crate std; + #[cfg(test)] + use std::println; + + #[test] + fn test_data_roundtrip() { + let data_slice = b"Hello, world!".as_slice(); + let mut buff = [0u8; 512]; + let data_packet = + SftpPacket::Data(ReqId(10), Data { data: BinString(data_slice) }); + + let mut sink = SftpSink::new(&mut buff); + data_packet.encode_response(&mut sink).expect("Failed to encode response"); + println!( + "data_packet encoded_len = {:?}, encoded = {:?}", + sink.payload_len(), + sink.payload_slice() + ); + let mut source = SftpSource::new(sink.used_slice()); + println!("source = {:?}", source); + + match SftpPacket::decode_response(&mut source) { + Ok(SftpPacket::Data(req_id, data)) => { + assert_eq!(req_id, ReqId(10)); + assert_eq!(data.data, BinString(data_slice)); + } + Ok(other) => panic!("Expected Data packet, got: {:?}", other), + Err(e) => panic!("Failed to decode packet: {:?}", e), + } + } + + #[test] + fn test_status_encoding() { + let mut buf = [0u8; 256]; + let mut sink = SftpSink::new(&mut buf); + let status_packet = SftpPacket::Status( + ReqId(16), + Status { + code: StatusCode::SSH_FX_EOF, + message: "A".into(), + lang: "en-US".into(), + }, + ); + + let expected_status_packet_slice: [u8; 27] = [ + 0, 0, 0, 23, // Packet len + 101, // Packet type + 0, 0, 0, 16, // ReqId + 0, 0, 0, 1, // Status code: SSH_FX_EOF + 0, 0, 0, 1, // string message length + 65, // string message content + 0, 0, 0, 5, // string lang length + 101, 110, 45, 85, 83, // string lang content + ]; + + let _ = status_packet.encode_response(&mut sink); + + assert_eq!(&expected_status_packet_slice, sink.used_slice()); + } + + #[test] + fn test_attributes_roundtrip() { + let mut buff = [0u8; MAX_NAME_ENTRY_SIZE]; + let attr_read_only = Attrs { + size: Some(1), + uid: Some(2), + gid: Some(3), + permissions: Some(222), + atime: Some(4), + mtime: Some(5), + ext_count: None, + }; + + let mut sink = SftpSink::new(&mut buff); + attr_read_only.enc(&mut sink).unwrap(); + println!( + "attr_read_only encoded_len = {:?}, encoded = {:?}", + sink.payload_len(), + sink.payload_slice() + ); + let mut source = SftpSource::new(sink.payload_slice()); + println!("source = {:?}", source); + + let a_r = Attrs::dec(&mut source); + match a_r { + Ok(attrs) => { + println!("source = {:?}", attrs); + assert_eq!(attr_read_only, attrs); + } + Err(e) => panic!("The attributes could not be decoded: {:?}", e), + } + } + + #[test] + fn test_packet_open_reading() { + let buff_open_read = [ + 0u8, 0, 0, + 58, // Len + 3, // SftpPacket + 0, 0, 0, + 4, // ReqId + 0, 0, 0, + 41, // Text String len + 46, 47, 100, 101, 109, 111, 47, 115, 102, + 116, // file Path + 112, 47, 115, 116, 100, 47, 116, 101, 115, 116, 105, 110, 103, 47, 111, + 117, 116, 47, 46, 47, 53, 49, 50, 66, 95, 114, 97, 110, 100, 111, + 109, // and 41 + 0, 0, 0, + 1, // PFlags: 1u32 == SSSH_FXF_READ + 0, 0, 0, + 0, // Attrib flags == 0 No flags, no attributes + ]; + + let mut source = SftpSource::new(&buff_open_read); + println!("source = {:?}", source); + + match SftpPacket::decode_request(&mut source) { + Ok(SftpPacket::Open(_req_id, open)) => { + assert_eq!(PFlags::SSH_FXF_READ, open.pflags); + } + Ok(other) => panic!("Expected Open packet, got: {:?}", other), + Err(e) => panic!("Failed to decode packet: {:?}", e), + } + } +} diff --git a/sftp/src/sftpsource.rs b/sftp/src/sftpsource.rs new file mode 100644 index 00000000..837ee0f1 --- /dev/null +++ b/sftp/src/sftpsource.rs @@ -0,0 +1,239 @@ +use crate::proto::{ + SftpNum, SFTP_FIELD_ID_INDEX, SFTP_FIELD_LEN_INDEX, SFTP_FIELD_LEN_LENGTH, + SFTP_FIELD_REQ_ID_INDEX, SFTP_FIELD_REQ_ID_LEN, +}; + +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) + } + + /// 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/sshwire-derive/src/lib.rs b/sshwire-derive/src/lib.rs index fed01188..462ca224 100644 --- a/sshwire-derive/src/lib.rs +++ b/sshwire-derive/src/lib.rs @@ -283,11 +283,6 @@ fn encode_enum( let atts = take_field_atts(&var.attributes)?; let mut rhs = StreamBuilder::new(); - if let Some(val) = &var.value { - // Avoid users expecting enum values to be encoded. - // Could be implemented if needed. - return Err(Error::Custom { error: "sunset_sshwire_derive::SSHEncode currently does not encode enum discriminants.".into(), span: Some(val.span())}) - } match var.fields { None => { // Unit enum @@ -305,7 +300,7 @@ fn encode_enum( } } - _ => return Err(Error::Custom { error: "sunset_sshwire_derive::SSHEncode currently only implements Unit or single value enum variants.".into(), span: None}) + _ => return Err(Error::Custom { error: "SSHEncode currently only implements Unit or single value enum variants.".into(), span: None}) } match_arm.puncts("=>"); From fd01170021148ef13dfdc0cc4f1e2d2d04b97838 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Wed, 4 Mar 2026 12:48:44 +1100 Subject: [PATCH 02/16] [skip ci] Adding sunset-sftp crate with basic SFTP server implementation - lib.rs: Now it contains the main library code for the sunset-sftp crate, including module declarations and public exports. Updated documentation to reflect the current state of the library and its features including issue #40. Main additions include: - sftphandler module: Implementation of the main entrypoint for the SFTP server, which will handle incoming SFTP requests and manage the server's state. - sftpserver.rs: Contains the trait definition for the SFTP server that is to be implemented by the user of the library, defining the required methods for handling SFTP operations. - sftperror.rs: Defines error types and handling for the SFTP server operations. Additional files: - sftpsink.rs: An implementation of SSHSink with extra functionality for handling SFTP packets - opaquefilehandle.rs: Collection of traits that a filehandle is expected to implement. About SftpHandler: Main entry point for the SFTP server. It requires to take ownership of an async_channel.rs::ChanInOut in order to write long responses to the client. This makes it not exactly sans-io and not completely observable, but this compromise facilitates the implementation of the SftpServer trait thanks to an internal embassy pipe (See sftpoutputchannelhandler.rs). --- sftp/src/lib.rs | 91 +-- sftp/src/opaquefilehandle.rs | 70 ++ sftp/src/sftperror.rs | 98 +++ sftp/src/sftphandler/mod.rs | 6 + sftp/src/sftphandler/requestholder.rs | 366 +++++++++ sftp/src/sftphandler/sftphandler.rs | 757 ++++++++++++++++++ .../sftphandler/sftpoutputchannelhandler.rs | 195 +++++ sftp/src/sftpserver.rs | 663 +++++++++++++++ sftp/src/sftpsink.rs | 99 +++ 9 files changed, 2300 insertions(+), 45 deletions(-) create mode 100644 sftp/src/opaquefilehandle.rs create mode 100644 sftp/src/sftperror.rs create mode 100644 sftp/src/sftphandler/mod.rs create mode 100644 sftp/src/sftphandler/requestholder.rs create mode 100644 sftp/src/sftphandler/sftphandler.rs create mode 100644 sftp/src/sftphandler/sftpoutputchannelhandler.rs create mode 100644 sftp/src/sftpserver.rs create mode 100644 sftp/src/sftpsink.rs diff --git a/sftp/src/lib.rs b/sftp/src/lib.rs index 24f7b363..f6cd7a3d 100644 --- a/sftp/src/lib.rs +++ b/sftp/src/lib.rs @@ -21,13 +21,14 @@ //! //! ## Basic features //! -//! - [ ] [SFTP Protocol Initialization](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-4) (Only SFTP V3 supported) -//! - [ ] [Canonicalizing the Server-Side Path Name](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.11) support -//! - [ ] [Open, close](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.3) +//! - [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) -//! - [ ] 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), -//! - [ ] File [stats](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) +//! - [x] Directory [Browsing](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7) +//! - [x] File [read](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4), +//! - [] File [write](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) with conditions. See [Server Channel Window length is reduced to zero when long data is sent from server to client](https://github.com/mkj/sunset/issues/40), +//! - [x] File [stats](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.8) //! //! ## Minimal features for convenient usability //! @@ -51,58 +52,58 @@ #![warn(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] -// mod opaquefilehandle; +mod opaquefilehandle; mod proto; -// mod sftperror; -// mod sftphandler; -// mod sftpserver; -// mod sftpsink; +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; +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 { +/// 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::*; + 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; + #[cfg(feature = "std")] + pub use crate::sftpserver::DirEntriesCollection; + #[cfg(feature = "std")] + pub use crate::sftpserver::get_file_attrs; + } + pub use crate::sftpsink::SftpSink; + pub use sunset::sshwire::SSHEncode; -// pub use crate::proto::MAX_REQUEST_LEN; -// } + pub use crate::proto::MAX_REQUEST_LEN; +} /// 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; -// } +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 { @@ -121,8 +122,8 @@ pub mod protocol { } } -// /// Errors and results used in this crate -// pub mod error { -// pub use crate::sftperror::SftpError; -// pub use crate::sftperror::SftpResult; -// } +/// 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/sftperror.rs b/sftp/src/sftperror.rs new file mode 100644 index 00000000..e19c253f --- /dev/null +++ b/sftp/src/sftperror.rs @@ -0,0 +1,98 @@ +use crate::protocol::StatusCode; + +use crate::sftphandler::requestholder::RequestHolderError; +use sunset::Error as SunsetError; +use sunset::sshwire::WireError; + +use core::convert::From; +use log::warn; + +/// Errors that are specific to this SFTP lib +#[derive(Debug)] +pub enum SftpError { + /// The SFTP server has not been initialised. No SFTP version has been + /// establish + NotInitialized, + /// An `SSH_FXP_INIT` packet was received after the server was already + /// initialized + AlreadyInitialized, + /// A packet could not be decoded as it was malformed + MalformedPacket, + /// The server does not have an implementation for the current request. + /// Some possible causes are: + /// + /// - The request has not been handled by an [`crate::sftpserver::SftpServer`] + /// - Long request which its handling was not implemented + NotSupported, + /// The connection has been closed by the client + ClientDisconnected, + /// The [`crate::sftpserver::SftpServer`] failed doing an IO operation + FileServerError(StatusCode), + // A RequestHolder instance throw an error. See [`crate::requestholder::RequestHolderError`] + /// A RequestHolder instance threw an error. See `RequestHolderError` + RequestHolderError(RequestHolderError), + /// A variant containing a [`WireError`] + WireError(WireError), + /// A variant containing a [`SunsetError`] + SunsetError(SunsetError), +} + +impl From for SftpError { + fn from(value: WireError) -> Self { + SftpError::WireError(value) + } +} + +impl From for SftpError { + fn from(value: SunsetError) -> Self { + SftpError::SunsetError(value) + } +} + +impl From for SftpError { + fn from(value: StatusCode) -> Self { + SftpError::FileServerError(value) + } +} + +impl From for SftpError { + fn from(value: RequestHolderError) -> Self { + SftpError::RequestHolderError(value) + } +} +// impl From for SftpError { +// fn from(value: FileServerError) -> Self { +// SftpError::FileServerError(value) +// } +// } + +impl From for WireError { + fn from(value: SftpError) -> Self { + match value { + SftpError::WireError(wire_error) => wire_error, + _ => WireError::PacketWrong, + } + } +} + +impl From for SunsetError { + fn from(value: SftpError) -> Self { + match value { + SftpError::SunsetError(error) => error, + SftpError::WireError(wire_error) => wire_error.into(), + SftpError::NotInitialized + | SftpError::NotSupported + | SftpError::AlreadyInitialized + | SftpError::MalformedPacket + | SftpError::RequestHolderError(_) + | SftpError::FileServerError(_) => { + warn!("Casting error loosing information: {:?}", value); + sunset::error::PacketWrong.build() + } + SftpError::ClientDisconnected => SunsetError::ChannelEOF, + } + } +} + +/// result specific to this SFTP lib +pub type SftpResult = Result; diff --git a/sftp/src/sftphandler/mod.rs b/sftp/src/sftphandler/mod.rs new file mode 100644 index 00000000..988cc09f --- /dev/null +++ b/sftp/src/sftphandler/mod.rs @@ -0,0 +1,6 @@ +pub mod requestholder; +mod sftphandler; +mod sftpoutputchannelhandler; + +pub use sftphandler::SftpHandler; +pub use sftpoutputchannelhandler::SftpOutputProducer; diff --git a/sftp/src/sftphandler/requestholder.rs b/sftp/src/sftphandler/requestholder.rs new file mode 100644 index 00000000..e962118f --- /dev/null +++ b/sftp/src/sftphandler/requestholder.rs @@ -0,0 +1,366 @@ +use crate::{ + proto::{MAX_REQUEST_LEN, SftpNum, SftpPacket}, + sftpsource::SftpSource, +}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; +use sunset::sshwire::WireError; + +#[derive(Debug)] +pub enum RequestHolderError { + /// The slice to hold is too long + NoRoom, + /// The slice holder is keeping a slice already. Consider cleaning + Busy, + /// The slice holder is empty + Empty, + /// There is not enough data in the slice we are trying to add. we need more data + RanOut, + /// The Packet held is not a request + NotRequest, + /// WireError + WireError(WireError), +} + +impl From for RequestHolderError { + fn from(value: WireError) -> Self { + RequestHolderError::WireError(value) + } +} + +pub(crate) type RequestHolderResult = Result; + +/// Helper struct to manage short fragmented requests that have been +/// received in consecutive read operations +/// +/// For requests exceeding the length of buffers other techniques, such +/// as composing them into multiple request, might help reducing the +/// required buffer sizes. This is recommended for restricted environments. +/// +/// The intended use for this RequestHolder is (in order): +/// - `new`: Initialize the struct with a slice that will keep the +/// request in memory +/// +/// - `try_hold`: load the data for an incomplete request +/// +/// - `try_append_for_valid_request`: append more data from another +/// slice to complete the request +/// +/// - `try_get_ref`: returns a reference to the portion of the slice +/// containing a request +/// +/// - `reset`: reset counters and flags to allow `try_hold` a new request +/// +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RequestHolder<'a> { + /// The buffer used to contain the data for the request + buffer: &'a mut [u8; MAX_REQUEST_LEN], + /// The index of the last byte in the buffer containing usable data + buffer_fill_index: usize, + /// Number of bytes appended in a previous `try_hold` or `try_append_for_valid_request` slice + appended: usize, + /// Used to mark when the structure is holding data + busy: bool, +} + +impl<'a> RequestHolder<'a> { + /// The buffer will be used to hold a full request. Choose a + /// reasonable size for this buffer. + pub(crate) fn new(buffer: &'a mut [u8; MAX_REQUEST_LEN]) -> Self { + RequestHolder { + buffer: buffer, + buffer_fill_index: 0, + busy: false, + appended: 0, + } + } + + /// Uses the internal buffer to store a copy of the provided slice + /// + /// The definition of `try_hold` and `try_append_slice` separately + /// is deliberated to follow an order in composing the held request + /// + /// Increases the `appended()` counter + /// + /// returns: + /// + /// - Ok(usize): the number of bytes read from the slice + /// + /// - `Err(Busy)`: If there has been a call to `try_hold` without a call to `reset` + pub(crate) fn try_hold(&mut self, slice: &[u8]) -> RequestHolderResult { + if self.busy { + return Err(RequestHolderError::Busy); + } + + self.busy = true; + self.try_append_slice(slice)?; + let read_in = self.appended(); + self.appended = 0; + Ok(read_in) + } + + /// Resets the structure allowing it to hold a new request. + /// + /// Resets the `appended()` counter. + /// + /// Will **clear** the previous data from the buffer. + pub(crate) fn reset(&mut self) -> () { + self.busy = false; + self.buffer_fill_index = 0; + self.appended = 0; + self.buffer.fill(0); + } + + /// Appends a byte at a time to the internal buffer and tries to + /// decode a request + /// + /// Reset and increase the `appended()` counter. + /// + /// **Returns**: + /// + /// - `Ok(())`: A valid request is held now + /// + /// - `Err(NotRequest)`: The decoded packet is not a request + /// + /// - `Err(RanOut)`: Not enough bytes in the slice to add a single byte + /// + /// - `Err(NoRoom)`: The internal buffer is full + /// + /// - `Err(Empty)`: If the structure has not been loaded with `try_hold` + /// + pub(crate) fn try_appending_for_valid_request( + &mut self, + slice_in: &[u8], + ) -> RequestHolderResult { + debug!( + "try_appending_for_valid_request: self = {:?}\n\ + Space left = {:?}\n\ + Length of slice to append from = {:?}", + self, + self.remaining_len(), + slice_in.len() + ); + + if !self.busy { + error!("Request Holder is not busy"); + return Err(RequestHolderError::Empty); + } + + self.appended = 0; // reset appended bytes counter. Try_append_slice will increase it + + if self.is_full() { + error!("Request Holder is full"); + return Err(RequestHolderError::NoRoom); + } + + if let Some(request) = self.valid_request() { + debug!("The request holder already contained a valid request"); + return Ok(request.sftp_num()); + } + + let mut slice = slice_in; + loop { + debug!( + "try_appending_for_valid_request: Slice length {:?}", + slice.len() + ); + if slice.len() > 0 { + self.try_append_slice(&[slice[0]])?; + slice = &slice[1..]; + let mut source = SftpSource::new(self.try_get_ref()?); + if let Ok(pt) = source.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::*; + + #[cfg(test)] + extern crate std; + #[cfg(test)] + use std::println; + + fn get_buffer_with_valid_request() -> [u8; 85] { + [ + 0, 0, 128, 25, 6, 0, 0, 0, 23, 0, 0, 0, 4, 249, 67, 81, 122, 0, 0, 0, 0, + 0, 9, 128, 0, 0, 0, 128, 0, 116, 101, 115, 116, 105, 110, 103, 47, 111, + 117, 116, 47, 49, 48, 48, 77, 66, 95, 114, 97, 110, 100, 111, 109, 0, 0, + 0, 26, 0, 0, 0, 4, 0, 0, 1, 164, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ] + } + #[test] + fn valid_request_uses_filled_data() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + assert!(rh.valid_request().is_none()); + } + + #[test] + fn try_appending_for_valid_request_uses_filled_data() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + assert!(rh.try_appending_for_valid_request(&buff_data[5..10]).is_err()); + } + + #[test] + fn try_appending_for_valid_request_works() { + let mut clean_buffer = [0u8; MAX_REQUEST_LEN]; + let buff_data = get_buffer_with_valid_request(); + println!("{buff_data:?}"); + + let mut rh = RequestHolder::new(&mut clean_buffer); + rh.try_hold(&buff_data).unwrap(); + assert!(rh.valid_request().is_some()); + + rh.reset(); + assert!(rh.valid_request().is_none()); + + rh.try_hold(&buff_data[..5]).unwrap(); + println!("before appending{rh:?}"); + let appending = rh.try_appending_for_valid_request(&buff_data[5..]); + // println!("{appending:?}",); + println!("after appending {rh:?}"); + assert!(appending.is_ok()); + } +} diff --git a/sftp/src/sftphandler/sftphandler.rs b/sftp/src/sftphandler/sftphandler.rs new file mode 100644 index 00000000..50500a50 --- /dev/null +++ b/sftp/src/sftphandler/sftphandler.rs @@ -0,0 +1,757 @@ +use crate::error::SftpError; +use crate::handles::OpaqueFileHandle; +use crate::proto::{ + self, InitVersionClient, InitVersionLowest, LStat, MAX_REQUEST_LEN, ReqId, + SFTP_VERSION, SftpNum, SftpPacket, Stat, StatusCode, +}; +use crate::server::{DirReply, ReadReply}; +use crate::sftperror::SftpResult; +use crate::sftphandler::requestholder::{RequestHolder, RequestHolderError}; +use crate::sftphandler::sftpoutputchannelhandler::{ + SftpOutputPipe, SftpOutputProducer, +}; +use crate::sftpserver::SftpServer; +use crate::sftpsource::SftpSource; + +use embassy_futures::select::select; +use sunset::Error as SunsetError; +use sunset::sshwire::{SSHSource, WireError}; +use sunset_async::ChanInOut; + +use core::u32; +use embedded_io_async::Read; +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// FSM for handling sftp requests during [`SftpHandler::process`] +#[derive(Default, Debug, PartialEq, Eq)] +enum HandlerState { + /// The handle is not been initialized. + /// if the client receivs an Init packet it will process it. + #[default] + Uninitialized, + /// The handle is ready to process requests. No request pending + /// A new packet will be evaluated to be process as: + /// - a regular request + /// - fragment (More data is needed) + /// - long request (It does not fit in the buffers and segmenting + /// strategies are used) + Idle, + /// The client has received a request and will decide how to process it. + /// Use the self.incomplete_request_holder + ProcessRequest { sftp_num: SftpNum }, + /// There is a fragmented request and more bytes are needed + /// Use the self.incomplete_request_holder + ProcessFragment, + /// A request, with a length over the incoming buffer capacity is being + /// processed. + /// + /// E.g. a write request with size exceeding the + /// buffer size: Processing this request will require to be split + /// into multiple write actions + ProcessWriteRequest { offset: u64, remaining_data: u32 }, + + /// Used to clear an invalid buffer in cases where there is still + /// data to be process but no longer required + ClearBuffer { data: usize }, +} + +/// Process the raw buffers in and out from a subsystem channel decoding +/// request and encoding responses +/// +/// It will delegate request to an [`crate::sftpserver::SftpServer`] +/// implemented by the library +/// user taking into account the local system details. +/// +/// The compiler time constant `BUFFER_OUT_SIZE` is used to define the +/// size of the output buffer for the subsystem [`Embassy-sync::pipe`] used +/// to send responses safely across the instantiated structure. +/// +pub struct SftpHandler<'a, T, S, const BUFFER_OUT_SIZE: usize> +where + T: OpaqueFileHandle, + S: SftpServer<'a, T>, +{ + /// Holds the internal state if the SFTP handle + state: HandlerState, + + /// The local SFTP File server implementing the basic SFTP requests + /// defined by [`crate::sftpserver::SftpServer`] + file_server: &'a mut S, + + // /// Use to process SFTP Write packets that have been received + // /// partially and the remaining is expected in successive buffers + // partial_write_request_tracker: Option>, + /// Used to handle received buffers that do not hold a complete request [`SftpPacket`] + request_holder: RequestHolder<'a>, + + /// Marker to keep track of the OpaqueFileHandle type + _marker: core::marker::PhantomData, +} + +impl<'a, T, S, const BUFFER_OUT_SIZE: usize> SftpHandler<'a, T, S, BUFFER_OUT_SIZE> +where + T: OpaqueFileHandle, + S: SftpServer<'a, T>, +{ + /// Creates a new instance of the structure. + /// + /// Requires: + /// + /// - `file_server` (implementing [`crate::sftpserver::SftpServer`] ): to execute + /// the request in the local system + /// - `request_buffer`: used to deal with fragmented + /// packets during [`SftpHandler::process_loop`] + pub fn new( + file_server: &'a mut S, + request_buffer: &'a mut [u8; MAX_REQUEST_LEN], + ) -> Self { + SftpHandler { + file_server, + state: HandlerState::default(), + request_holder: RequestHolder::new(request_buffer), + _marker: core::marker::PhantomData, + } + } + + /// Take the [`ChanInOut`] and locks, Processing all the request from stdio until + /// an EOF is received + pub async fn process_loop( + &mut self, + stdio: ChanInOut<'a>, + buffer_in: &mut [u8], + ) -> SftpResult<()> { + let (mut chan_in, chan_out) = stdio.split(); + + let mut sftp_output_pipe = SftpOutputPipe::::new(); + + let (mut output_consumer, output_producer) = + sftp_output_pipe.split(chan_out)?; + + let output_consumer_loop = output_consumer.receive_task(); + + let processing_loop = async { + loop { + trace!("SFTP: About to read bytes from SSH Channel"); + let lr: usize = match chan_in.read(buffer_in).await { + Ok(lr) => lr, + Err(e) => match e { + SunsetError::NoRoom {} => { + error!("SSH channel is full"); + continue; + } + _ => return Err(e.into()), + }, + }; + + debug!("SFTP <---- received: {:?} bytes", lr); + trace!("SFTP <---- received: {:?}", &buffer_in[0..lr]); + if lr == 0 { + debug!("client disconnected"); + return Err(SftpError::ClientDisconnected); + } + + self.process(&buffer_in[0..lr], &output_producer).await?; + } + #[allow(unreachable_code)] + SftpResult::Ok(()) + }; + match select(processing_loop, output_consumer_loop).await { + embassy_futures::select::Either::First(r) => { + error!("Processing returned: {:?}", r); + r + } + embassy_futures::select::Either::Second(r) => { + error!("Output consumer returned: {:?}", r); + r + } + } + } + + /// - Decodes the buffer_in request + /// - Process the request delegating + /// operations to a [`SftpServer`] implementation + /// - Serializes an answer in `output_producer` + /// + async fn process( + &mut self, + buffer_in: &[u8], + output_producer: &SftpOutputProducer<'_, BUFFER_OUT_SIZE>, + ) -> SftpResult<()> { + /* + Possible scenarios: + - Init: The init handshake has to be performed. Only Init packet is accepted. NAV(Idle) + - handshake?: The client has received an Init packet and is processing it. NAV( Init, Idle) + - Idle: Ready to process request. No request pending. In this point. NAV(ProcessRequest, Fragment) + - Fragment: There is a fragmented request and more data is needed. NAV(ProcessRequest, ProcessLongRequest) + - ProcessRequest: The client has received a request and is processing it. NAV(Idle) + - ProcessLongRequest: The client has received a request that cannot fit in the buffer. Special treatment is required. NAV(Idle) + */ + let mut buf = buffer_in; + + trace!("Received {:} bytes to process", buf.len()); + + // We used `run_another_loop` to bypass the buf len check in + // cases where we need to process data held + // TODO: Fix this pattern + let mut skip_checking_buffer = false; + trace!("Entering loop to process the full received buffer"); + while skip_checking_buffer || buf.len() > 0 { + debug!( + "<=======================[ SFTP Process State: {:?} ]=======================> Buffer remaining: {}", + self.state, + buf.len() + ); + skip_checking_buffer = false; + match &self.state { + HandlerState::ProcessWriteRequest { + offset, + remaining_data: data_len, + } => { + if let Some(request) = self.request_holder.valid_request() { + if let SftpPacket::Write(req_id, write) = request { + let used = (*data_len as usize).min(buf.len()); + let remaining_data = *data_len - used as u32; + + let data = &buf[..used]; + buf = &buf[used..]; + match self + .file_server + .write(&T::try_from(&write.handle)?, *offset, data) + .await + { + Ok(_) => { + if remaining_data == 0 { + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_OK, + "", + ) + .await?; + trace!("Still in buffer: {buf:?}"); + self.state = HandlerState::Idle; + } else { + self.state = + HandlerState::ProcessWriteRequest { + offset: *offset + (used as u64), + remaining_data, + }; + } + } + Err(e) => { + error!("SFTP write thrown: {:?}", e); + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "error writing", + ) + .await?; + self.state = HandlerState::ClearBuffer { + data: remaining_data as usize, + }; + } + }; + } else { + todo!("Wrong transition? Uncontrolled for now"); + } + } else { + todo!("Wrong transition? Uncontrolled for now"); + } + } + HandlerState::Uninitialized => { + debug!("Creating a source: buf_len = {:?}", buf.len()); + let mut source = SftpSource::new(&buf); + + match SftpPacket::decode_request(&mut source) { + Ok(request) => match request { + SftpPacket::Init(InitVersionClient { + version: SFTP_VERSION, + }) => { + debug!( + "Accepted initialization request: {:?}", + request + ); + output_producer + .send_packet(&SftpPacket::Version( + InitVersionLowest { version: SFTP_VERSION }, + )) + .await?; + buf = &buf[buf.len() - source.remaining()..]; + self.state = HandlerState::Idle; + } + SftpPacket::Init(init_version_client) => { + error!( + "Incompatible SFTP Version: {:?} is not {SFTP_VERSION:?}", + &init_version_client + ); + return Err(SftpError::NotSupported); + } + _ => { + error!( + "Wrong SFTP Packet before Init or incompatible version: {request:?}" + ); + return Err(SftpError::NotInitialized); + } + }, + Err(e) => { + error!("Malformed SFTP Packet before Init: {e:?}"); + return Err(SftpError::MalformedPacket); + } // Err(e) => { + // error!("Malformed SFTP Packet before Init: {e:?}"); + // return Err(SftpError::MalformedPacket); + // } + } + } + HandlerState::Idle => { + self.request_holder.reset(); + debug!("Creating a source: buf_len = {:?}", buf.len()); + let mut source = SftpSource::new(&buf); + trace!("source: {source:?}"); + + match SftpPacket::decode_request(&mut source) { + Ok(request) => { + debug!("Got a valid request {:?}", request.sftp_num()); + self.request_holder.try_hold(&source.buffer_used())?; + + // We got the request. Moving on to process it before deserializing more + // data + skip_checking_buffer = true; + self.state = HandlerState::ProcessRequest { + sftp_num: request.sftp_num(), + }; + // TODO Wasteful. Will have to decode the request again. Maybe hold it? + buf = &buf[buf.len() - source.remaining()..]; + } + Err(WireError::RanOut) => { + debug!("source: {source:?}"); + let rl = self + .request_holder + .try_hold(&source.consume_all())?; + + buf = &buf[buf.len() - source.remaining()..]; + debug!( + "Incomplete packet. request holder initialized with {rl:?} bytes" + ); + self.state = HandlerState::ProcessFragment; + } + Err(WireError::UnknownPacket { number }) => { + error!("Unknown packet: {number}"); + output_producer + .send_status( + ReqId( + source + .peak_packet_req_id() + .unwrap_or(u32::MAX), + ), + StatusCode::SSH_FX_OP_UNSUPPORTED, + "", + ) + .await?; + buf = &buf[buf.len() - source.remaining()..]; + debug!( + "Unknown Packet. clearing the buffer in place since it filts" + ); + } + Err(WireError::PacketWrong) => { + error!("Not a request: "); + output_producer + .send_status( + ReqId( + source + .peak_packet_req_id() + .unwrap_or(u32::MAX), + ), + StatusCode::SSH_FX_BAD_MESSAGE, + "Not a request", + ) + .await?; + } + Err(e) => { + error!("Unexpected error: Bug!"); + return Err(SftpError::WireError(e)); + } + }; + } + HandlerState::ProcessFragment => { + match self.request_holder.try_appending_for_valid_request(&buf) { + Ok(sftp_num) => { + let used = self.request_holder.appended(); + debug!( + "{used:?} bytes added. We got a complete request: {sftp_num:?}:: {:?}", + self.request_holder + ); + debug!( + "Request: {:?}", + self.request_holder.valid_request() + ); + buf = &buf[used..]; + self.state = HandlerState::ProcessRequest { sftp_num } + } + Err(RequestHolderError::RanOut) => { + let used = self.request_holder.appended(); + buf = &buf[used..]; + debug!( + "{used:?} bytes added. Will keep adding \ + until we hold a valid request" + ); + } + Err(RequestHolderError::NoRoom) => { + error!( + "Could not complete the request. holding buffer is full" + ); + return Err(SunsetError::Bug.into()); + } + Err(e) => { + error!("{e:?}"); + return Err(e.into()); + } + } + } + HandlerState::ProcessRequest { .. } => { + // At this point the assumption is that the request holder will contain + // a full valid request (Lets call this an invariant) + + if let Some(request) = self.request_holder.valid_request() { + if !request.sftp_num().is_request() { + error!( + "Unexpected SftpPacket: {:?}", + request.sftp_num() + ); + return Err(SunsetError::BadUsage {}.into()); + } + match request { + // SftpPacket::Init(init_version_client) => todo!(), + // SftpPacket::Version(init_version_lowest) => todo!(), + SftpPacket::Read(req_id, ref read) => { + debug!("Read request: {:?}", request); + + let mut reply = + ReadReply::new(req_id, output_producer); + if let Err(error) = self + .file_server + .read( + &T::try_from(&read.handle)?, + read.offset, + read.len, + &mut reply, + ) + .await + { + error!("Error reading data: {:?}", error); + if let SftpError::FileServerError(status) = error + { + output_producer + .send_status( + req_id, + status, + "Could not list attributes", + ) + .await?; + } else { + output_producer + .send_status( + req_id, + StatusCode::SSH_FX_FAILURE, + "Could not list attributes", + ) + .await?; + } + }; + + match reply.read_diff() { + diff if diff > 0 => { + debug!( + "ReadReply not completed after read operation. Still need to send {} bytes", + diff + ); + return Err(SunsetError::Bug.into()); + } + diff if diff < 0 => { + error!( + "ReadReply has sent more data than announced: {} bytes extra", + -diff + ); + return Err(SunsetError::Bug.into()); + } + _ => {} + } + + self.state = HandlerState::Idle; + } + SftpPacket::LStat(req_id, LStat { file_path: path }) => { + match self + .file_server + .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::Init(..) + | SftpPacket::Version(..) + | SftpPacket::Status(..) + | SftpPacket::Handle(..) + | SftpPacket::Data(..) + | SftpPacket::Name(..) + | SftpPacket::Attrs(..) => { + error!( + "Unexpected SftpPacket in ProcessRequest state: {:?}", + request.sftp_num() + ); + return Err(SunsetError::BadUsage {}.into()); + } + } + } else { + return Err(SunsetError::bug().into()); + } + } + HandlerState::ClearBuffer { data } => { + if *data == 0 { + self.state = HandlerState::Idle; + } else { + buf = &buf[(*data).min(buf.len())..] + } + } + } + trace!("Process will check buf len {:?}", buf.len()); + } + debug!("Whole buffer processed. Getting more data"); + Ok(()) + } +} diff --git a/sftp/src/sftphandler/sftpoutputchannelhandler.rs b/sftp/src/sftphandler/sftpoutputchannelhandler.rs new file mode 100644 index 00000000..3843e8e8 --- /dev/null +++ b/sftp/src/sftphandler/sftpoutputchannelhandler.rs @@ -0,0 +1,195 @@ +use crate::error::{SftpError, SftpResult}; +use crate::proto::{ReqId, SftpPacket, Status, StatusCode}; +use crate::server::SftpSink; + +use embassy_sync::mutex::Mutex; +use sunset_async::ChanOut; + +use embassy_sync::pipe::{Pipe, Reader as PipeReader, Writer as PipeWriter}; +use embedded_io_async::Write; +use sunset_async::SunsetRawMutex; + +use log::{debug, error, trace}; + +type CounterMutex = Mutex; + +pub struct SftpOutputPipe { + pipe: Pipe, + counter_send: CounterMutex, + counter_recv: CounterMutex, + splitted: bool, +} + +/// M: SunsetSunsetRawMutex +impl SftpOutputPipe { + /// Creates an empty SftpOutputPipe. + /// The output channel will be consumed during the split call + /// + /// Usage: + /// + /// let output_pipe = SftpOutputPipe::::new(); + /// + pub fn new() -> Self { + SftpOutputPipe { + pipe: Pipe::new(), + counter_send: Mutex::::new(0), + counter_recv: Mutex::::new(0), + splitted: false, + } + } + + /// Get a Consumer and Producer pair so the producer can send data to the + /// output channel without mutable borrows. + /// + /// The [`SftpOutputConsumer`] needs to be running to write data to the + /// [`ChanOut`] + /// + /// ## Lifetimes + /// The lifetime indicates that the lifetime of self, ChanOut and the + /// consumer and producer are the same. I chose this because if the ChanOut + /// is closed, there is no point on having a pipe outliving it. + pub fn split<'a>( + &'a mut self, + ssh_chan_out: ChanOut<'a>, + ) -> SftpResult<(SftpOutputConsumer<'a, N>, SftpOutputProducer<'a, N>)> { + if self.splitted { + return Err(SftpError::AlreadyInitialized); + } + self.splitted = true; + let (reader, writer) = self.pipe.split(); + Ok(( + SftpOutputConsumer { reader, ssh_chan_out, counter: &self.counter_recv }, + SftpOutputProducer { writer, counter: &self.counter_send }, + )) + } +} + +/// Consumer that takes ownership of [`ChanOut`]. It pipes the data received +/// from a [`PipeReader`] into the channel +pub(crate) struct SftpOutputConsumer<'a, const N: usize> { + reader: PipeReader<'a, SunsetRawMutex, N>, + ssh_chan_out: ChanOut<'a>, + counter: &'a CounterMutex, +} + +impl<'a, const N: usize> SftpOutputConsumer<'a, N> { + /// Run it to start the piping + pub async fn receive_task(&mut self) -> SftpResult<()> { + debug!("Running SftpOutout Consumer Reader task"); + let mut buf = [0u8; N]; + loop { + let rl = self.reader.read(&mut buf).await; + let mut _total = 0; + { + let mut lock = self.counter.lock().await; + *lock += rl; + _total = *lock; + } + + debug!("Output Consumer: ---> Reads {rl} bytes. Total {_total}"); + let mut scanning_buffer = &buf[..rl]; + if rl > 0 { + // Replaced write_all with loop to handle partial writes to discard issues in write_all + while scanning_buffer.len() > 0 { + trace!( + "Output Consumer: Tries to write {:?} bytes to ChanOut", + scanning_buffer.len() + ); + let wl = self.ssh_chan_out.write(scanning_buffer).await?; + debug!("Output Consumer: Written {:?} bytes ", wl); + if wl < scanning_buffer.len() { + debug!( + "Output Consumer: ChanOut accepted only part of the buffer" + ); + } + trace!( + "Output Consumer: Bytes written {:?}", + &scanning_buffer[..wl] + ); + scanning_buffer = &scanning_buffer[wl..]; + } + debug!("Output Consumer: Finished writing all bytes in read buffer"); + } else { + error!("Output Consumer: Empty array received"); + } + } + } +} + +/// Producer used to send data to a [`ChanOut`] without the restrictions +/// of mutable borrows +#[derive(Clone)] +pub struct SftpOutputProducer<'a, const N: usize> { + writer: PipeWriter<'a, SunsetRawMutex, N>, + counter: &'a CounterMutex, +} +impl<'a, const N: usize> SftpOutputProducer<'a, N> { + /// Sends the data encoded in the provided [`SftpSink`] without including + /// the size. + /// + /// Use this when you are sending chunks of data after a valid header + pub async fn send_data(&self, buf: &[u8]) -> SftpResult<()> { + Self::send_buffer(&self.writer, &buf, &self.counter).await; + Ok(()) + } + + /// Simplifies the task of sending a status response to the client. + pub async fn send_status( + &self, + req_id: ReqId, + status: StatusCode, + msg: &'static str, + ) -> SftpResult<()> { + let response = SftpPacket::Status( + req_id, + Status { code: status, message: msg.into(), lang: "en-US".into() }, + ); + trace!("Output Producer: Pushing a status message: {:?}", response); + self.send_packet(&response).await?; + Ok(()) + } + + /// Sends a SFTP Packet into the channel out, including the length field + pub async fn send_packet(&self, packet: &SftpPacket<'_>) -> SftpResult<()> { + let mut buf = [0u8; N]; + let mut sink = SftpSink::new(&mut buf); + packet.encode_response(&mut sink)?; + debug!("Output Producer: Sending packet {:?}", packet); + Self::send_buffer(&self.writer, &sink.used_slice(), &self.counter).await; + Ok(()) + } + + /// Internal associated method to log the writes to the pipe + async fn send_buffer( + writer: &PipeWriter<'a, SunsetRawMutex, N>, + buf: &[u8], + counter: &CounterMutex, + ) { + let mut _total = 0; + { + let mut lock = counter.lock().await; + *lock += buf.len(); + _total = *lock; + } + + debug!("Output Producer: <--- Sends {:?} bytes. Total {_total}", buf.len()); + trace!("Output Producer: Sending buffer {:?}", buf); + + // writer.write_all(buf); // ??? error[E0596]: cannot borrow `*writer` as mutable, as it is behind a `&` reference + + let mut buf = buf; + loop { + if buf.len() == 0 { + break; + } + + trace!("Output Producer: Tries to send {:?} bytes", buf.len()); + let bytes_sent = writer.write(&buf).await; + buf = &buf[bytes_sent..]; + trace!( + "Output Producer: sent {bytes_sent:?}. {:?} bytes remain ", + buf.len() + ); + } + } +} diff --git a/sftp/src/sftpserver.rs b/sftp/src/sftpserver.rs new file mode 100644 index 00000000..35019f55 --- /dev/null +++ b/sftp/src/sftpserver.rs @@ -0,0 +1,663 @@ +use crate::error::{SftpError, SftpResult}; +use crate::proto::{ + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH, ENCODED_SSH_FXP_DATA_MIN_LENGTH, + MAX_NAME_ENTRY_SIZE, NameEntry, PFlags, SftpNum, +}; +use crate::server::SftpSink; +use crate::sftphandler::SftpOutputProducer; +use crate::{ + handles::OpaqueFileHandle, + proto::{Attrs, ReqId, StatusCode}, +}; + +use sunset::sshwire::SSHEncode; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// Result used to store the result of an Sftp Operation +pub type SftpOpResult = core::result::Result; + +/// To finish read requests the server needs to answer to +/// **subsequent READ requests** after all the data has been sent already +/// with a [`SftpPacket`] including a status code [`StatusCode::SSH_FX_EOF`]. +/// +/// [`ReadStatus`] enum has been implemented to keep record of these exhausted +/// read operations. +/// +/// See: +/// +/// - [Reading and Writing](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.4) +/// - [Scanning Directories](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-6.7) +#[derive(PartialEq, Debug, Default)] +pub enum ReadStatus { + /// There is more data to be read therefore the [`SftpServer`] will + /// send more data in the next read request. + #[default] + PendingData, + /// The server has provided all the data requested therefore the [`SftpServer`] + /// will send a [`SftpPacket`] including a status code [`StatusCode::SSH_FX_EOF`] + /// in the next read request. + EndOfFile, +} + +/// All trait functions are optional in the SFTP protocol. +/// Some less core operations have a Provided implementation returning +/// returns `SSH_FX_OP_UNSUPPORTED`. Common operations must be implemented, +/// but may return `Err(StatusCode::SSH_FX_OP_UNSUPPORTED)`. +pub trait SftpServer<'a, T> +where + T: OpaqueFileHandle, +{ + /// Opens a file for reading/writing + fn open( + &'_ mut self, + path: &str, + mode: &PFlags, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Open operation not defined: path = {:?}, attrs = {:?}", + path, + mode + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Close either a file or directory handle + fn close( + &mut self, + handle: &T, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Close operation not defined: handle = {:?}", + handle + ); + + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + /// Reads from a file that has previously being opened for reading + /// + /// ## Notes to the implementer: + /// + /// The implementer is expected to use the parameter `reply` [`DirReply`] to: + /// + /// - In case of no more data is to be sent, call `reply.send_eof()` + /// - There is more data to be sent from an open file: + /// 1. Call `reply.send_header()` with the length of data to be sent + /// 2. Call `reply.send_data()` once or multiple times to send all the data announced + /// 3. Do not call `reply.send_eof()` during this [`readdir`] method call + /// + + /// If the length communicated in the header does not match the total length of the data + /// sent using `reply.send_data()`, the SFTP session will be broken. + /// + #[allow(unused)] + fn read( + &mut self, + opaque_file_handle: &T, + offset: u64, + len: u32, + reply: &mut ReadReply<'_, N>, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Read operation not defined: handle = {:?}, offset = {:?}, len = {:?}", + opaque_file_handle, + offset, + len + ); + Err(SftpError::FileServerError(StatusCode::SSH_FX_OP_UNSUPPORTED)) + } + } + /// Writes to a file that has previously being opened for writing + fn write( + &mut self, + opaque_file_handle: &T, + offset: u64, + buf: &[u8], + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer Write operation not defined: handle = {:?}, offset = {:?}, buf = {:?}", + opaque_file_handle, + offset, + buf + ); + Ok(()) + } + } + + /// Opens a directory and returns a handle + fn opendir( + &mut self, + dir: &str, + ) -> impl core::future::Future> { + async move { + log::error!("SftpServer OpenDir operation not defined: dir = {:?}", dir); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Reads the list of items in a directory and returns them using the [`DirReply`] + /// parameter. + /// + /// ## Notes to the implementer: + /// + /// The implementer is expected to use the parameter `reply` [`DirReply`] to: + /// + /// - In case of no more items in the directory to send, call `reply.send_eof()` + /// - There are more items in the directory: + /// 1. Call `reply.send_header()` with the number of items and the [`SSHEncode`] + /// length of all the items to be sent + /// 2. Call `reply.send_item()` for each of the items announced to be sent + /// 3. Do not call `reply.send_eof()` during this [`readdir`] method call + /// + /// If the length communicated in the header does not match the total length of all + /// the items sent using `reply.send_item()`, the SFTP session will be + /// broken. + /// + /// The server is expected to keep track of the number of items that remain to be sent + /// to the client since the client will only stop asking for more elements in the + /// directory when a read dir request is answer with an reply.send_eof() + /// + #[allow(unused_variables)] + fn readdir( + &mut self, + opaque_dir_handle: &T, + reply: &mut DirReply<'_, N>, + ) -> impl core::future::Future> { + async move { + log::error!( + "SftpServer ReadDir operation not defined: handle = {:?}", + opaque_dir_handle + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Provides the real path of the directory specified + fn realpath( + &mut self, + dir: &str, + ) -> impl core::future::Future>> { + async move { + log::error!( + "SftpServer RealPath operation not defined: dir = {:?}", + dir + ); + Err(StatusCode::SSH_FX_OP_UNSUPPORTED) + } + } + + /// Provides the 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) + } + } +} + +/// A reference structure passed to the [`SftpServer::read()`] method to +/// allow replying with the read data. +/// Uses for [`ReadReply`] to: +/// +/// - In case of no more data avaliable to be sent, call `reply.send_eof()` +/// - There is data to be sent from an open file: +/// 1. Call `reply.send_header()` with the length of data to be sent +/// 2. Call `reply.send_data()` as many times as needed to complete a +/// sent of data of the announced length +/// 3. Do not call `reply.send_eof()` during this [`read`] method call +/// +/// It handles immutable sending data via the underlying sftp-channel +/// [`sunset_async::async_channel::ChanOut`] used in the context of an +/// SFTP Session. +/// +pub struct ReadReply<'g, const N: usize> { + /// The request Id that will be use`d in the response + req_id: ReqId, + + /// Immutable writer + chan_out: &'g SftpOutputProducer<'g, N>, + /// Length of data to be sent as announced in [`ReadReply::send_header`] + data_len: u32, + /// Length of data sent so far using [`ReadReply::send_data`] + data_sent_len: u32, +} + +impl<'g, const N: usize> ReadReply<'g, N> { + /// New instances can only be created within the crate. Users can only + /// use other public methods to use it. + pub(crate) fn new( + req_id: ReqId, + chan_out: &'g SftpOutputProducer<'g, N>, + ) -> Self { + ReadReply { req_id, chan_out, data_len: 0, data_sent_len: 0 } + } + + // TODO Make this enforceable + // TODO Automate encoding the SftpPacket + /// Sends a header for `SSH_FXP_DATA` response. This includes the total + /// response length, the packet type, request id and data length + /// + /// The packet data content, excluding the length must be sent using + /// [`ReadReply::send_data`] + pub async fn send_header(&mut self, data_len: u32) -> SftpResult<()> { + debug!( + "ReadReply: Sending header for request id {:?}: data length = {:?}", + self.req_id, data_len + ); + let mut s = [0u8; N]; + let mut sink = SftpSink::new(&mut s); + + let payload = + ReadReply::::encode_data_header(&mut sink, self.req_id, data_len)?; + + debug!( + "Sending header: len = {:?}, content = {:?}", + payload.len(), + payload + ); + // Sending payload_slice since we are not making use of the sink sftpPacket length calculation + self.chan_out.send_data(payload).await?; + self.data_len = data_len; + Ok(()) + } + + /// Sends a buffer with data. Call it as many times as needed to send + /// the announced data length + /// + /// **Important**: Call this after you have called `send_header` + pub async fn send_data(&mut self, buff: &[u8]) -> SftpResult<()> { + self.chan_out.send_data(buff).await?; + self.data_sent_len += buff.len() as u32; + Ok(()) + } + + /// Sends EOF meaning that there is no more data to be sent + /// + pub async fn send_eof(&self) -> SftpResult<()> { + self.chan_out.send_status(self.req_id, StatusCode::SSH_FX_EOF, "").await + } + + /// Indicates whether all the data announced in the header has been sent + /// + /// returns 0 when all data has been sent + /// returns >0 when there is still data to be sent + /// returns <0 when too much data has been sent + pub fn read_diff(&self) -> i32 { + (self.data_len as i32) - (self.data_sent_len as i32) + } + + fn encode_data_header( + sink: &'g mut SftpSink<'g>, + req_id: ReqId, + data_len: u32, + ) -> Result<&'g [u8], SftpError> { + // length field + (data_len + ENCODED_SSH_FXP_DATA_MIN_LENGTH).enc(sink)?; + // packet type (1) + u8::from(SftpNum::SSH_FXP_DATA).enc(sink)?; + // request id (4) + req_id.enc(sink)?; + // data length (4) + data_len.enc(sink)?; + Ok(sink.payload_slice()) + } +} + +#[cfg(test)] +mod read_reply_tests { + use super::*; + + #[cfg(test)] + extern crate std; + // #[cfg(test)] + // use std::println; + + #[test] + fn compose_header() { + const N: usize = 512; + + let req_id = ReqId(42); + let data_len = 128; + let mut buffer = [0u8; N]; + let mut sink = SftpSink::new(&mut buffer); + + let payload = + ReadReply::::encode_data_header(&mut sink, req_id, data_len).unwrap(); + + assert_eq!( + data_len + ENCODED_SSH_FXP_DATA_MIN_LENGTH, + u32::from_be_bytes(payload[..4].try_into().unwrap()) + ); + } +} + +/// Uses for [`DirReply`] to: +/// +/// - In case of no more items in the directory to be sent, call `reply.send_eof()` +/// - There are more items in the directory to be sent: +/// 1. Call `reply.send_header()` with the number of items and the [`SSHEncode`] +/// length of all the items to be sent +/// 2. Call `reply.send_item()` for each of the items announced to be sent +/// 3. Do not call `reply.send_eof()` during this [`readdir`] method call +/// +/// It handles immutable sending data via the underlying sftp-channel +/// [`sunset_async::async_channel::ChanOut`] used in the context of an +/// SFTP Session. +/// +pub struct DirReply<'g, const N: usize> { + /// The request Id that will be use`d in the response + req_id: ReqId, + /// Immutable writer + chan_out: &'g SftpOutputProducer<'g, N>, + /// Length of data to be sent as announced in [`ReadReply::send_header`] + data_len: u32, + /// Length of data sent so far using [`ReadReply::send_data`] + data_sent_len: u32, +} + +impl<'g, const N: usize> DirReply<'g, N> { + // const ENCODED_NAME_SFTP_PACKET_LENGTH: u32 = 9; + + /// New instances can only be created within the crate. Users can only + /// use other public methods to use it. + pub(crate) fn new( + req_id: ReqId, + chan_out: &'g SftpOutputProducer<'g, N>, + ) -> Self { + // DirReply { chan_out: chan_out_wrapper, req_id } + DirReply { req_id, chan_out, data_len: 0, data_sent_len: 0 } + } + + // TODO Make this enforceable + // TODO Automate encoding the SftpPacket + /// Sends the header to the client with the number of files as [`NameEntry`] and the [`SSHEncode`] + /// length of all these [`NameEntry`] items + pub async fn send_header( + &mut self, + count: u32, + items_encoded_len: u32, + ) -> SftpResult<()> { + debug!( + "I will send the header here for request id {:?}: count = {:?}, length = {:?}", + self.req_id, count, items_encoded_len + ); + let mut s = [0u8; N]; + let mut sink = SftpSink::new(&mut s); + + let payload = DirReply::::encode_data_header( + &mut sink, + self.req_id, + items_encoded_len, + count, + )?; + + debug!( + "Sending header: len = {:?}, content = {:?}", + payload.len(), + payload + ); + self.chan_out.send_data(payload).await?; + self.data_len = items_encoded_len; + Ok(()) + } + + /// Sends a directory item to the client as a [`NameEntry`] + /// + /// Call this + pub async fn send_item(&mut self, name_entry: &NameEntry<'_>) -> SftpResult<()> { + let mut buffer = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut sftp_sink = SftpSink::new(&mut buffer); + name_entry.enc(&mut sftp_sink).map_err(|err| { + error!("WireError: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + + self.chan_out.send_data(sftp_sink.payload_slice()).await?; + self.data_sent_len += sftp_sink.payload_len() as u32; + Ok(()) + } + + /// Sends EOF meaning that there is no more files in the directory + pub async fn send_eof(&self) -> SftpResult<()> { + self.chan_out.send_status(self.req_id, StatusCode::SSH_FX_EOF, "").await + } + + /// Indicates whether all the data announced in the header has been sent + /// + /// returns 0 when all data has been sent + /// returns >0 when there is still data to be sent + /// returns <0 when too much data has been sent + pub fn read_diff(&self) -> i32 { + (self.data_len as i32) - (self.data_sent_len as i32) + } + + fn encode_data_header( + sink: &'g mut SftpSink<'g>, + req_id: ReqId, + items_encoded_len: u32, + count: u32, + ) -> Result<&'g [u8], SftpError> { + // We need to consider the packet type, Id and count fields + // This way I collect data required for the header and collect + // valid entries into a vector (only std) + (items_encoded_len + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH).enc(sink)?; + u8::from(SftpNum::SSH_FXP_NAME).enc(sink)?; + req_id.enc(sink)?; + count.enc(sink)?; + + Ok(sink.payload_slice()) + } +} + +#[cfg(test)] +mod dir_reply_tests { + use super::*; + + #[cfg(test)] + extern crate std; + // #[cfg(test)] + // use std::println; + + #[test] + fn compose_header() { + const N: usize = 512; + + let req_id = ReqId(42); + let data_len = 128; + let count = 128; + let mut buffer = [0u8; N]; + let mut sink = SftpSink::new(&mut buffer); + + let payload = + DirReply::::encode_data_header(&mut sink, req_id, data_len, count) + .unwrap(); + + // println!("{payload:?}"); + + // println!("{:?}", &u32::from_be_bytes(payload[..4].try_into().unwrap())); + assert_eq!( + data_len + ENCODED_BASE_NAME_SFTP_PACKET_LENGTH, + u32::from_be_bytes(payload[..4].try_into().unwrap()) + ); + } +} + +pub mod helpers { + use crate::{ + error::SftpResult, + proto::{MAX_NAME_ENTRY_SIZE, NameEntry}, + server::SftpSink, + }; + + use sunset::sshwire::SSHEncode; + + /// Helper function to get the length of a given [`NameEntry`] + /// as it would be serialized to the wire. + /// + /// Use this function to calculate the total length of a collection + /// of `NameEntry`s in order to send a correct response Name header + pub fn get_name_entry_len(name_entry: &NameEntry<'_>) -> SftpResult { + let mut buf = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut temp_sink = SftpSink::new(&mut buf); + name_entry.enc(&mut temp_sink)?; + Ok(temp_sink.payload_len() as u32) + } +} + +#[cfg(feature = "std")] +use crate::proto::Filename; +#[cfg(feature = "std")] +use std::{ + fs::{DirEntry, Metadata, ReadDir}, + os::{linux::fs::MetadataExt, unix::fs::PermissionsExt}, + time::SystemTime, +}; + +#[cfg(feature = "std")] +/// This is a helper structure to make ReadDir into something manageable for +/// [`DirReply`] +#[derive(Debug)] +pub struct DirEntriesCollection { + /// Number of elements + count: u32, + /// Computed length of all the encoded elements + encoded_length: u32, + /// The actual entries. As you can see these are DirEntry. This is a std choice + entries: Vec, +} + +#[cfg(feature = "std")] +impl DirEntriesCollection { + /// Creates this DirEntriesCollection so linux std users do not need to + /// translate `std` directory elements into Sftp structures before sending a response + /// back to the client + pub fn new(dir_iterator: ReadDir) -> SftpOpResult { + use log::info; + + let mut encoded_length = 0; + + let entries: Vec = dir_iterator + .filter_map(|entry_result| { + let entry = entry_result.ok()?; + let filename = entry.file_name().to_string_lossy().into_owned(); + let name_entry = NameEntry { + filename: Filename::from(filename.as_str()), + _longname: Filename::from(""), + attrs: Self::get_attrs_or_empty(entry.metadata()), + }; + + let mut buffer = [0u8; MAX_NAME_ENTRY_SIZE]; + let mut sftp_sink = SftpSink::new(&mut buffer); + name_entry.enc(&mut sftp_sink).ok()?; + encoded_length += u32::try_from(sftp_sink.payload_len()) + .map_err(|_| StatusCode::SSH_FX_FAILURE) + .ok()?; + Some(entry) + }) + .collect(); + + let count = + u32::try_from(entries.len()).map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + info!( + "Processed {} entries, estimated serialized length: {}", + count, encoded_length + ); + + Ok(Self { count, encoded_length, entries }) + } + + /// Using the provided [`DirReply`] sends a response taking care of + /// composing a SFTP Entry header and sending everything in the right order + /// + /// Returns a [`ReadStatus`] + pub async fn send_response( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult { + self.send_entries_header(reply).await?; + self.send_entries(reply).await?; + Ok(ReadStatus::EndOfFile) + } + /// Sends a header for all the elements in the ReadDir iterator + /// + /// It will take care of counting them and finding the serialized length of each + /// element + async fn send_entries_header( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + reply.send_header(self.count, self.encoded_length).await.map_err(|e| { + debug!("Could not send header {e:?}"); + StatusCode::SSH_FX_FAILURE + }) + } + + /// Sends the entries in the ReadDir iterator back to the client + async fn send_entries( + &self, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + for entry in &self.entries { + let filename = entry.file_name().to_string_lossy().into_owned(); + let attrs = Self::get_attrs_or_empty(entry.metadata()); + let name_entry = NameEntry { + filename: Filename::from(filename.as_str()), + _longname: Filename::from(""), + attrs, + }; + debug!("Sending new item: {:?}", name_entry); + reply.send_item(&name_entry).await.map_err(|err| { + error!("SftpError: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + } + Ok(()) + } + + fn get_attrs_or_empty( + maybe_metadata: Result, + ) -> Attrs { + maybe_metadata.map(get_file_attrs).unwrap_or_default() + } +} + +#[cfg(feature = "std")] +/// [`std`] helper function to get [`Attrs`] from a [`Metadata`]. +pub fn get_file_attrs(metadata: Metadata) -> Attrs { + let time_to_u32 = |time_result: std::io::Result| { + time_result + .ok()? + .duration_since(SystemTime::UNIX_EPOCH) + .ok()? + .as_secs() + .try_into() + .ok() + }; + + Attrs { + size: Some(metadata.len()), + uid: Some(metadata.st_uid()), + gid: Some(metadata.st_gid()), + permissions: Some(metadata.permissions().mode()), + atime: time_to_u32(metadata.accessed()), + mtime: time_to_u32(metadata.modified()), + ext_count: None, + } +} diff --git a/sftp/src/sftpsink.rs b/sftp/src/sftpsink.rs new file mode 100644 index 00000000..31fb0c76 --- /dev/null +++ b/sftp/src/sftpsink.rs @@ -0,0 +1,99 @@ +use crate::proto::SFTP_FIELD_LEN_LENGTH; + +use sunset::sshwire::{SSHSink, WireError}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +/// A implementation fo [`SSHSink`] that observes some constraints for +/// SFTP packets +/// +/// **Important**: It needs to be [`SftpSink::finalize`] to add the packet +/// len +#[derive(Default)] +pub struct SftpSink<'g> { + buffer: &'g mut [u8], + index: usize, +} + +impl<'g> SftpSink<'g> { + /// Initializes the Sink, with the particularity that it will leave + /// [`crate::proto::SFTP_FIELD_LEN_LENGTH`] bytes empty at the + /// start of the buffer that will contain the total packet length + /// once the [`SftpSink::finalize`] method is called + pub fn new(s: &'g mut [u8]) -> Self { + SftpSink { buffer: s, index: SFTP_FIELD_LEN_LENGTH } + } + + /// Finalise the buffer by prepending the packet length field, + /// excluding the field itself. + /// + /// **Returns** the final index in the buffer as a reference of the + /// space used + fn finalize(&mut self) -> usize { + if self.index <= SFTP_FIELD_LEN_LENGTH { + warn!("SftpSink trying to terminate it before pushing data"); + return 0; + } // size is 0 + let used_size = self.payload_len() as u32; + + used_size + .to_be_bytes() + .iter() + .enumerate() + .for_each(|(i, v)| self.buffer[i] = *v); + + self.index + } + + /// Auxiliary method to allow seen the len used by the encoded payload + pub fn payload_len(&self) -> usize { + self.index - SFTP_FIELD_LEN_LENGTH + } + + /// Auxiliary method to allow an immutable reference to the encoded payload + /// excluding the `u32` length field prepended to it + pub fn payload_slice(&self) -> &[u8] { + &self.buffer + [SFTP_FIELD_LEN_LENGTH..SFTP_FIELD_LEN_LENGTH + self.payload_len()] + } + + /// Auxiliary method to allow an immutable reference to the full used + /// data (includes the prepended length field) + /// + /// **Important:** Call this after [`SftpSink::finalize()`] + pub fn used_slice(&self) -> &[u8] { + debug!( + "SftpSink used_slice called, total len: {}. Index: {}", + SFTP_FIELD_LEN_LENGTH + self.payload_len(), + self.index + ); + &self.buffer[..SFTP_FIELD_LEN_LENGTH + self.payload_len()] + } + + /// Reset the index and cleans the length field + pub fn reset(&mut self) -> () { + debug!("SftpSink reset called when index was {:?}", self.index); + self.index = SFTP_FIELD_LEN_LENGTH; + for i in 0..SFTP_FIELD_LEN_LENGTH { + self.buffer[i] = 0; + } + } +} + +impl<'g> SSHSink for SftpSink<'g> { + fn push(&mut self, v: &[u8]) -> sunset::sshwire::WireResult<()> { + if v.len() + self.index > self.buffer.len() { + return Err(WireError::NoRoom); + } + trace!("Sink index: {:}", self.index); + v.iter().for_each(|val| { + trace!("Writing val {:} at index {:}", *val, self.index); + self.buffer[self.index] = *val; + self.index += 1; + }); + trace!("Sink new index: {:}", self.index); + self.finalize(); + Ok(()) + } +} From 1f38033b8029e385892372b68f45814eebeca602 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Wed, 4 Mar 2026 13:20:54 +1100 Subject: [PATCH 03/16] [skip ci] Adding @mkj contributions troubleshooting channel reception challenges and other changes to sunset From 'matt/sftptesting' into dev/sftp-start commits: - 947cd6e99b36c71e18ba9929d42bfe0c741d83be - 286950160ccafbf39d5f9c3068994b5d0f070ab0 And @jubeormk1 - a44f70c9c3430f8470a0d51985747591daa4511c --- src/channel.rs | 12 ++++++++++-- src/encrypt.rs | 4 +++- src/packets.rs | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/channel.rs b/src/channel.rs index 10c66142..dd80814d 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -211,7 +211,14 @@ impl Channels { let ch = self.get_mut(num)?; ch.finished_input(len); if let Some(w) = ch.check_window_adjust()? { - s.send(w)?; + match s.send(w) { + Ok(_) => ch.pending_adjust = 0, + Err(Error::NoRoom { .. }) => { + // TODO better retry rather than hoping a retry occurs + debug!("noroom for adjustment") + } + error => return error, + } } Ok(()) } @@ -1028,11 +1035,12 @@ impl Channel { } /// Returns a window adjustment packet if required + /// + /// Does not reset the adjustment to 0, should be done by caller on successful send. fn check_window_adjust(&mut self) -> Result>> { let num = self.send.as_mut().trap()?.num; if self.pending_adjust > self.full_window / 2 { let adjust = self.pending_adjust as u32; - self.pending_adjust = 0; let p = packets::ChannelWindowAdjust { num, adjust }.into(); Ok(Some(p)) } else { diff --git a/src/encrypt.rs b/src/encrypt.rs index 34c71ca7..bc520c87 100644 --- a/src/encrypt.rs +++ b/src/encrypt.rs @@ -130,7 +130,9 @@ impl KeyState { buf: &mut [u8], ) -> Result { let e = self.enc.encrypt(payload_len, buf, self.seq_encrypt.0); - self.seq_encrypt += 1; + if !matches!(e, Err(Error::NoRoom { .. })) { + self.seq_encrypt += 1; + } e } diff --git a/src/packets.rs b/src/packets.rs index 6ffe97d2..a96c9a93 100644 --- a/src/packets.rs +++ b/src/packets.rs @@ -841,7 +841,7 @@ pub struct DirectTcpip<'a> { pub struct Unknown<'a>(pub &'a [u8]); impl<'a> Unknown<'a> { - fn new(u: &'a [u8]) -> Self { + pub fn new(u: &'a [u8]) -> Self { let u = Unknown(u); trace!("saw unknown variant \"{u}\""); u @@ -882,7 +882,7 @@ pub struct ParseContext { // Set to true if an unknown variant is encountered. // Packet length checks should be omitted in that case. - pub(crate) seen_unknown: bool, + pub seen_unknown: bool, } impl ParseContext { From f06643c476367193eeddb8cf0d014360c7cf8a14 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Wed, 4 Mar 2026 14:03:12 +1100 Subject: [PATCH 04/16] [skip ci] WIP: Adding demo sftp std example and testing TODO: Improve the tests. Running them is a bit hairy. You need to run the demo server from the root of the repo, and then run the test scripts from the testing folder. This is an implementation of the sunset-sftp basic functionality. It also has a testing folder with scripts to test different operations --- Cargo.lock | 26 + Cargo.toml | 1 + demo/sftp/std/Cargo.toml | 40 ++ demo/sftp/std/README.md | 64 +++ demo/sftp/std/debug_sftp_client.sh | 5 + demo/sftp/std/rust-toolchain.toml | 3 + demo/sftp/std/src/demofilehandlemanager.rs | 63 +++ demo/sftp/std/src/demoopaquefilehandle.rs | 37 ++ demo/sftp/std/src/demosftpserver.rs | 444 ++++++++++++++++++ demo/sftp/std/src/main.rs | 231 +++++++++ demo/sftp/std/tap.sh | 7 + demo/sftp/std/testing/extract_txrx.sh | 43 ++ .../std/testing/log_demo_sftp_with_test.sh | 106 +++++ demo/sftp/std/testing/log_get_single_long.sh | 1 + demo/sftp/std/testing/log_get_single_short.sh | 1 + demo/sftp/std/testing/merge_logs.sh | 9 + demo/sftp/std/testing/test_get.sh | 53 +++ demo/sftp/std/testing/test_get_long.sh | 45 ++ demo/sftp/std/testing/test_get_short.sh | 45 ++ .../std/testing/test_long_write_requests.sh | 38 ++ demo/sftp/std/testing/test_read_dir.sh | 33 ++ demo/sftp/std/testing/test_stats.sh | 27 ++ demo/sftp/std/testing/test_write_requests.sh | 44 ++ 23 files changed, 1366 insertions(+) create mode 100644 demo/sftp/std/Cargo.toml create mode 100644 demo/sftp/std/README.md create mode 100755 demo/sftp/std/debug_sftp_client.sh create mode 100644 demo/sftp/std/rust-toolchain.toml create mode 100644 demo/sftp/std/src/demofilehandlemanager.rs create mode 100644 demo/sftp/std/src/demoopaquefilehandle.rs create mode 100644 demo/sftp/std/src/demosftpserver.rs create mode 100644 demo/sftp/std/src/main.rs create mode 100755 demo/sftp/std/tap.sh create mode 100755 demo/sftp/std/testing/extract_txrx.sh create mode 100755 demo/sftp/std/testing/log_demo_sftp_with_test.sh create mode 100755 demo/sftp/std/testing/log_get_single_long.sh create mode 100755 demo/sftp/std/testing/log_get_single_short.sh create mode 100755 demo/sftp/std/testing/merge_logs.sh create mode 100755 demo/sftp/std/testing/test_get.sh create mode 100755 demo/sftp/std/testing/test_get_long.sh create mode 100755 demo/sftp/std/testing/test_get_short.sh create mode 100755 demo/sftp/std/testing/test_long_write_requests.sh create mode 100755 demo/sftp/std/testing/test_read_dir.sh create mode 100755 demo/sftp/std/testing/test_stats.sh create mode 100755 demo/sftp/std/testing/test_write_requests.sh diff --git a/Cargo.lock b/Cargo.lock index 8874a498..534121f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2813,6 +2813,32 @@ dependencies = [ "sunset-sshwire-derive", ] +[[package]] +name = "sunset-demo-sftp-std" +version = "0.1.2" +dependencies = [ + "async-io", + "critical-section", + "embassy-executor", + "embassy-futures", + "embassy-net", + "embassy-net-tuntap", + "embassy-sync 0.7.2", + "embassy-time", + "embedded-io-async", + "env_logger", + "fnv", + "heapless", + "libc", + "log", + "rand", + "sha2", + "sunset", + "sunset-async", + "sunset-demo-common", + "sunset-sftp", +] + [[package]] name = "sunset-demo-std" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b5635e3b..391503f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ rust-version = "1.87" members = [ "demo/picow", "demo/std", + "demo/sftp/std", "fuzz", "stdasync", "sftp", diff --git a/demo/sftp/std/Cargo.toml b/demo/sftp/std/Cargo.toml new file mode 100644 index 00000000..a2f08d6f --- /dev/null +++ b/demo/sftp/std/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "sunset-demo-sftp-std" +version = "0.1.2" +edition = "2021" + +[dependencies] +sunset = { workspace = true, features = ["rsa", "std"] } +sunset-async.workspace = true +sunset-demo-common.workspace = true +sunset-sftp = { version = "0.1.0", path = "../../../sftp", features = ["std"] } + +# 131072 was determined empirically +embassy-executor = { version = "0.7", features = [ + "executor-thread", "arch-std", "log", "task-arena-size-131072"] } +embassy-net = { version = "0.7", features = ["tcp", "dhcpv4", "medium-ethernet"] } +embassy-net-tuntap = { version = "0.1" } +embassy-sync = { version = "0.7" } +embassy-futures = { version = "0.1" } +# embassy-time dep required to link a time driver +embassy-time = { version = "0.4", default-features=false, features = ["log", "std"] } + +log = { version = "0.4" } +# default regex feature is huge +env_logger = { version = "0.11", default-features=false, features = ["auto-color", "humantime"] } + +embedded-io-async = "0.6" +heapless = "0.8" + +# for tuntap +libc = "0.2.101" +async-io = "1.6.0" + +# using local fork +# menu = "0.3" + + +critical-section = "1.1" +rand = { version = "0.8", default-features = false, features = ["getrandom"] } +sha2 = { version = "0.10", default-features = false } +fnv = "1.0.7" diff --git a/demo/sftp/std/README.md b/demo/sftp/std/README.md new file mode 100644 index 00000000..98394a10 --- /dev/null +++ b/demo/sftp/std/README.md @@ -0,0 +1,64 @@ +# sunset-demo-sftp-std + +`demo/sftp/std` contains a host-side (`std`) demo that runs an SSH server with SFTP support using the `sunset` and `sunset-sftp` crates. It runs on linux distributions. + +It is intended as a **reference implementation** for building your own SFTP server with `sunset-sftp`. It is not a complete implementation and you should make your own choices for your sftp server. + +In particular, this demo shows how to: + +- implement an `SftpServer` for request handling +- add a `FileHandleManager` to track/open/close active handles +- define an `OpaqueFileHandle` format to safely encode/decode handle IDs across requests + +Use `src/demosftpserver.rs`, `src/demofilehandlemanager.rs`, and `src/demoopaquefilehandle.rs` together with `main.rs` and common demo files as a reference for custom server development. + +## What this folder contains + +- `src/main.rs` + Demo entry point. Sets up logging, runtime/executor, network stack, and starts the SSH/SFTP demo server. +- `src/demosftpserver.rs` + Demo SFTP server wiring and request handling glue. +- `src/demofilehandlemanager.rs` + Tracks and manages open file handles used by the SFTP session. +- `src/demoopaquefilehandle.rs` + Defines/encodes opaque file handle values used by the demo protocol layer. +- `tap.sh` + Helper script to create/configure a TAP interface for local testing. +- `debug_sftp_client.sh` + Convenience script for running an SFTP client in a debug-friendly way. +- `testing/` + Test and log scripts (read/write/stat/readdir scenarios, log helpers, and parsing utilities). + +## Setup + +This demo uses a tap interface to run the server and accept connections. The tap.sh sets this up in a linux environment. I have not find a way to run this on MacOS. On windows I recommend using WSL2. + +Run: + +```bash +sudo ./tap.sh +``` + +## Build / run + +From base project folder `sunset`: + +```bash +cargo run -p sunset-demo-sftp-std +``` + +Then connect with an SFTP client using the configured demo host/user settings. The first info log will display the server ipv4 address. + +## Testing + +`testing/` contains runnable scripts and utilities to validate SFTP behavior end-to-end. It includes scenarios for: + +- file reads/writes +- `stat`/metadata checks +- directory listing (`readdir`) +- log capture and parsing helpers (Requires a tshark installation with the current user in wireshark group) + +These scripts are useful both for regression checks and as examples of expected server behavior during development. + +these scripts have been used through the development of `sunset-sftp` and might not respond to a general use but some particular troubleshooting. I hope that they are useful as a reference for you exploration. + diff --git a/demo/sftp/std/debug_sftp_client.sh b/demo/sftp/std/debug_sftp_client.sh new file mode 100755 index 00000000..f67b1df6 --- /dev/null +++ b/demo/sftp/std/debug_sftp_client.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# This sftp options are meant to help debugging and do not store any host key or known hosts information. +# That is not a good practice in real life, as it can lead to security issues, but it is useful for debugging purposes. + +sftp -vvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR any@192.168.69.2 \ No newline at end of file diff --git a/demo/sftp/std/rust-toolchain.toml b/demo/sftp/std/rust-toolchain.toml new file mode 100644 index 00000000..9993e936 --- /dev/null +++ b/demo/sftp/std/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = [ "rustfmt" ] diff --git a/demo/sftp/std/src/demofilehandlemanager.rs b/demo/sftp/std/src/demofilehandlemanager.rs new file mode 100644 index 00000000..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..2a703bce --- /dev/null +++ b/demo/sftp/std/src/demosftpserver.rs @@ -0,0 +1,444 @@ +use crate::demofilehandlemanager::DemoFileHandleManager; + +use sunset_sftp::error::SftpResult; +use sunset_sftp::handles::{OpaqueFileHandle, OpaqueFileHandleManager, PathFinder}; +use sunset_sftp::protocol::{Attrs, Filename, NameEntry, PFlags, StatusCode}; +use sunset_sftp::server::helpers::DirEntriesCollection; +use sunset_sftp::server::{ + DirReply, ReadReply, ReadStatus, SftpOpResult, SftpServer, +}; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::{fs::File, os::unix::fs::FileExt, path::Path}; + +// Used during read operations +const ARBITRARY_READ_BUFFER_LENGTH: usize = 1024; + +#[derive(Debug)] +pub(crate) enum PrivatePathHandle { + File(PrivateFileHandle), + Directory(PrivateDirHandle), +} + +#[derive(Debug)] +pub(crate) struct PrivateFileHandle { + path: String, + permissions: Option, + file: File, +} + +#[derive(Debug)] +pub(crate) struct PrivateDirHandle { + path: String, + read_status: ReadStatus, +} + +/// It is a better practice generating it on creation. Used to generate the opaque handles instead of using a constant +static OPAQUE_SALT: &'static str = "12d%32"; + +impl PathFinder for PrivatePathHandle { + fn matches(&self, path: &Self) -> bool { + match self { + PrivatePathHandle::File(self_private_path_handler) => { + if let PrivatePathHandle::File(private_file_handle) = path { + return self_private_path_handler.matches(private_file_handle); + } else { + false + } + } + PrivatePathHandle::Directory(self_private_dir_handle) => { + if let PrivatePathHandle::Directory(private_dir_handle) = path { + self_private_dir_handle.matches(private_dir_handle) + } else { + false + } + } + } + } + + fn get_path_ref(&self) -> &str { + match self { + PrivatePathHandle::File(private_file_handler) => { + private_file_handler.get_path_ref() + } + PrivatePathHandle::Directory(private_dir_handle) => { + private_dir_handle.get_path_ref() + } + } + } +} + +impl PathFinder for PrivateFileHandle { + fn matches(&self, path: &PrivateFileHandle) -> bool { + self.path.as_str().eq_ignore_ascii_case(path.get_path_ref()) + } + + fn get_path_ref(&self) -> &str { + self.path.as_str() + } +} + +impl PathFinder for PrivateDirHandle { + fn matches(&self, path: &PrivateDirHandle) -> bool { + self.path.as_str().eq_ignore_ascii_case(path.get_path_ref()) + } + + fn get_path_ref(&self) -> &str { + self.path.as_str() + } +} + +/// A basic demo server. Used as a demo and to test SFTP functionality +pub struct DemoSftpServer { + base_path: String, + handles_manager: DemoFileHandleManager, +} + +impl DemoSftpServer { + pub fn new(base_path: String) -> Self { + if !Path::new(&base_path).exists() { + debug!("Base path {:?} does not exist. Creating it", base_path); + if let Err(err) = fs::create_dir_all(&base_path) { + error!("Could not create the base path {:?}: {:?}", base_path, err); + panic!(); + } + } else { + debug!("Base path {:?} already exists", base_path); + } + DemoSftpServer { base_path, handles_manager: DemoFileHandleManager::new() } + } +} + +impl SftpServer<'_, OFH> for DemoSftpServer { + async fn open(&mut self, filename: &str, mode: &PFlags) -> SftpOpResult { + debug!("Open file: filename = {:?}, mode = {:?}", filename, mode); + + let can_write = u32::from(mode) & u32::from(&PFlags::SSH_FXF_WRITE) > 0; + let can_read = u32::from(mode) & u32::from(&PFlags::SSH_FXF_READ) > 0; + + info!( + "File open for read/write access: can_read={:?}, can_write={:?}", + can_read, can_write + ); + + let file = File::options() + .read(can_read) + .write(can_write) + .create(can_write) + .open(filename) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + let permissions = file + .metadata() + .map_err(|_| StatusCode::SSH_FX_FAILURE)? + .permissions() + .mode() + & 0o777; + + let fh = self.handles_manager.insert( + PrivatePathHandle::File(PrivateFileHandle { + path: filename.into(), + permissions: Some(permissions), + file, + }), + OPAQUE_SALT, + ); + + debug!( + "Filename \"{:?}\" will have the obscured file handle: {:?}", + filename, fh + ); + + fh + } + + async fn opendir(&mut self, dir: &str) -> SftpOpResult { + info!("Open Directory = {:?}", dir); + + let dir_handle = self.handles_manager.insert( + PrivatePathHandle::Directory(PrivateDirHandle { + path: dir.into(), + read_status: ReadStatus::default(), + }), + OPAQUE_SALT, + ); + + debug!( + "Directory \"{:?}\" will have the obscured file handle: {:?}", + dir, dir_handle + ); + + dir_handle + } + + async fn realpath(&mut self, dir: &str) -> SftpOpResult> { + info!("finding path for: {:?}", dir); + let name_entry = NameEntry { + filename: Filename::from(self.base_path.as_str()), + _longname: Filename::from(""), + attrs: Attrs { + size: None, + uid: None, + gid: None, + permissions: None, + atime: None, + mtime: None, + ext_count: None, + }, + }; + debug!("Will return: {:?}", name_entry); + Ok(name_entry) + } + + async fn close(&mut self, opaque_file_handle: &OFH) -> SftpOpResult<()> { + if let Some(handle) = self.handles_manager.remove(opaque_file_handle) { + match handle { + PrivatePathHandle::File(private_file_handle) => { + info!( + "SftpServer Close operation on file {:?} was successful", + private_file_handle.path + ); + drop(private_file_handle.file); // Not really required but illustrative + Ok(()) + } + PrivatePathHandle::Directory(private_dir_handle) => { + info!( + "SftpServer Close operation on dir {:?} was successful", + private_dir_handle.path + ); + + Ok(()) + } + } + } else { + error!( + "SftpServer Close operation on handle {:?} failed", + opaque_file_handle + ); + Err(StatusCode::SSH_FX_FAILURE) + } + } + + async fn read( + &mut self, + opaque_file_handle: &OFH, + offset: u64, + len: u32, + reply: &mut ReadReply<'_, N>, + ) -> SftpResult<()> { + if let PrivatePathHandle::File(private_file_handle) = self + .handles_manager + .get_private_as_mut_ref(opaque_file_handle) + .ok_or(StatusCode::SSH_FX_FAILURE)? + { + log::debug!( + "SftpServer Read operation: handle = {:?}, filepath = {:?}, offset = {:?}, len = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + len + ); + let permissions_poxit = private_file_handle.permissions.unwrap_or(0o000); + if (permissions_poxit & 0o444) == 0 { + error!( + "No read permissions for file {:?}", + private_file_handle.path + ); + return Err(StatusCode::SSH_FX_PERMISSION_DENIED.into()); + }; + + let file_len = private_file_handle + .file + .metadata() + .map_err(|err| { + error!("Could not read the file length: {:?}", err); + StatusCode::SSH_FX_FAILURE + })? + .len(); + + if offset >= file_len { + info!( + "offset is larger than file length, sending EOF for {:?}", + private_file_handle.path + ); + reply.send_eof().await.map_err(|err| { + error!("Could not sent EOF: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + return Ok(()); + } + + let read_len = if file_len >= len as u64 + offset { + len + } else { + debug!("Read operation: length + offset > file length. Clipping ( {:?} + {:?} > {:?})", + len, offset, file_len); + (file_len - offset).try_into().unwrap_or(u32::MAX) + }; + + reply.send_header(read_len).await?; + + let mut read_buff = [0u8; ARBITRARY_READ_BUFFER_LENGTH]; + + let mut running_offset = offset; + let mut remaining = read_len as usize; + + debug!("Starting reading loop: remaining = {}", remaining); + while remaining > 0 { + let next_read_len: usize = remaining.min(read_buff.len()); + trace!("next_read_len = {}", next_read_len); + let br = private_file_handle + .file + .read_at(&mut read_buff[..next_read_len], running_offset) + .map_err(|err| { + error!("read error: {:?}", err); + StatusCode::SSH_FX_FAILURE + })?; + trace!("{} bytes readed", br); + reply.send_data(&read_buff[..br.min(remaining)]).await?; + trace!("Read sent {} bytes", br.min(remaining)); + trace!("remaining {} bytes. {} byte read", remaining, br); + + remaining = + remaining.checked_sub(br).ok_or(StatusCode::SSH_FX_FAILURE)?; + trace!( + "after subtracting {} bytes, there are {} bytes remaining", + br, + remaining + ); + running_offset = running_offset + .checked_add(br as u64) + .ok_or(StatusCode::SSH_FX_FAILURE)?; + } + debug!("Finished sending data"); + return Ok(()); + } + Err(StatusCode::SSH_FX_PERMISSION_DENIED.into()) + } + + async fn write( + &mut self, + opaque_file_handle: &OFH, + offset: u64, + buf: &[u8], + ) -> SftpOpResult<()> { + if let PrivatePathHandle::File(private_file_handle) = self + .handles_manager + .get_private_as_ref(opaque_file_handle) + .ok_or(StatusCode::SSH_FX_FAILURE)? + { + let permissions_poxit = (private_file_handle + .permissions + .ok_or(StatusCode::SSH_FX_PERMISSION_DENIED))?; + + if (permissions_poxit & 0o222) == 0 { + return Err(StatusCode::SSH_FX_PERMISSION_DENIED); + }; + + log::trace!( + "SftpServer Write operation: handle = {:?}, filepath = {:?}, offset = {:?}, buf = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + String::from_utf8(buf.to_vec()) + ); + let bytes_written = private_file_handle + .file + .write_at(buf, offset) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; + + log::debug!( + "SftpServer Write operation: handle = {:?}, filepath = {:?}, offset = {:?}, buffer length = {:?}, bytes written = {:?}", + opaque_file_handle, + private_file_handle.path, + offset, + buf.len(), + bytes_written + ); + + Ok(()) + } else { + Err(StatusCode::SSH_FX_PERMISSION_DENIED) + } + } + + async fn readdir( + &mut self, + opaque_dir_handle: &OFH, + reply: &mut DirReply<'_, N>, + ) -> SftpOpResult<()> { + info!("read dir for {:?}", opaque_dir_handle); + + if let PrivatePathHandle::Directory(dir) = self + .handles_manager + .get_private_as_mut_ref(opaque_dir_handle) + .ok_or(StatusCode::SSH_FX_NO_SUCH_FILE)? + { + if dir.read_status == ReadStatus::EndOfFile { + reply.send_eof().await.map_err(|error| { + error!("{:?}", error); + StatusCode::SSH_FX_FAILURE + })?; + return Ok(()); + } + + let path_str = dir.path.clone(); + debug!("opaque handle found in handles manager: {:?}", path_str); + let dir_path = Path::new(&path_str); + debug!("path: {:?}", dir_path); + + if dir_path.is_dir() { + info!("SftpServer ReadDir operation path = {:?}", dir_path); + + let dir_iterator = fs::read_dir(dir_path).map_err(|err| { + error!("could not get the directory {:?}: {:?}", path_str, err); + StatusCode::SSH_FX_PERMISSION_DENIED + })?; + + let name_entry_collection = DirEntriesCollection::new(dir_iterator)?; + + let response_read_status = + name_entry_collection.send_response(reply).await?; + + dir.read_status = response_read_status; + return Ok(()); + } else { + error!("the path is not a directory = {:?}", dir_path); + return Err(StatusCode::SSH_FX_NO_SUCH_FILE); + } + } else { + error!("Could not find the directory for {:?}", opaque_dir_handle); + return Err(StatusCode::SSH_FX_NO_SUCH_FILE); + } + } + + async fn 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..efc7835b --- /dev/null +++ b/demo/sftp/std/src/main.rs @@ -0,0 +1,231 @@ +use sunset::*; +use sunset_async::{ProgressHolder, SSHServer, SunsetMutex, SunsetRawMutex}; +use sunset_sftp::{server::MAX_REQUEST_LEN, SftpHandler}; + +pub(crate) use sunset_demo_common as demo_common; + +use demo_common::{DemoCommon, DemoServer, SSHConfig}; + +use crate::{ + demoopaquefilehandle::DemoOpaqueFileHandle, demosftpserver::DemoSftpServer, +}; + +use embassy_executor::Spawner; +use embassy_net::{Stack, StackResources, StaticConfigV4}; + +use rand::rngs::OsRng; +use rand::RngCore; + +use embassy_futures::select::select; +use embassy_net_tuntap::TunTapDevice; +use embassy_sync::channel::Channel; + +#[allow(unused_imports)] +use log::{debug, error, info, log, trace, warn}; + +mod demofilehandlemanager; +mod demoopaquefilehandle; +mod demosftpserver; + +const NUM_LISTENERS: usize = 4; +// +1 for dhcp +const NUM_SOCKETS: usize = NUM_LISTENERS + 1; + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, TunTapDevice>) -> ! { + runner.run().await +} + +#[embassy_executor::task] +async fn main_task(spawner: Spawner) { + let opt_tap0 = "tap0"; + let ip4 = "192.168.69.2"; + let cir = 24; + + let config = Box::leak(Box::new({ + let mut config = SSHConfig::new().unwrap(); + config.set_admin_pw(Some("pw")).unwrap(); + config.console_noauth = true; + config.ip4_static = if let Ok(ip) = ip4.parse() { + Some(StaticConfigV4 { + address: embassy_net::Ipv4Cidr::new(ip, cir), + gateway: None, + dns_servers: { heapless::Vec::new() }, + }) + } else { + None + }; + SunsetMutex::new(config) + })); + + let net_cf = if let Some(ref s) = config.lock().await.ip4_static { + embassy_net::Config::ipv4_static(s.clone()) + } else { + embassy_net::Config::dhcpv4(Default::default()) + }; + info!("Net config: {net_cf:?}"); + + // Init network device + let net_device = TunTapDevice::new(opt_tap0).unwrap(); + + let seed = OsRng.next_u64(); + + // Init network stack + let res = Box::leak(Box::new(StackResources::::new())); + let (stack, runner) = embassy_net::new(net_device, net_cf, res, seed); + + // Launch network task + spawner.spawn(net_task(runner)).unwrap(); + + for _ in 0..NUM_LISTENERS { + spawner.spawn(listen(stack, config)).unwrap(); + } +} + +#[derive(Default)] +struct StdDemo; + +impl DemoServer for StdDemo { + async fn run(&self, serv: &SSHServer<'_>, mut common: DemoCommon) -> Result<()> { + let chan_pipe = Channel::::new(); + + let ssh_loop_inner = async { + loop { + let mut ph = ProgressHolder::new(); + let ev = match serv.progress(&mut ph).await { + Ok(event) => event, + Err(e) => { + match e { + Error::NoRoom {} => { + warn!("NoRoom triggered. Trying again"); + continue; + } + _ => { + error!("server progress failed: {:?}", e); // NoRoom: 2048 Bytes Output buffer + return Err(e); + } + } + } + }; + + trace!("ev {ev:?}"); + match ev { + ServEvent::SessionShell(a) => { + a.fail()?; // Not allowed in this example, kept here for compatibility + } + ServEvent::SessionExec(a) => { + a.fail()?; // Not allowed in this example, kept here for compatibility + } + ServEvent::SessionSubsystem(a) => { + match a.command()?.to_lowercase().as_str() { + "sftp" => { + info!("Starting '{}' subsystem", a.command()?); + + if let Some(ch) = common.sess.take() { + debug_assert!(ch.num() == a.channel()); + a.succeed()?; + let _ = chan_pipe.try_send(ch); + } else { + a.fail()?; + } + } + _ => { + warn!( + "request for subsystem '{}' not implemented: fail", + a.command()? + ); + a.fail()?; + } + } + } + other => common.handle_event(other)?, + }; + } + #[allow(unreachable_code)] + Ok::<_, Error>(()) + }; + + let ssh_loop = async { + info!("prog_loop started"); + if let Err(e) = ssh_loop_inner.await { + warn!("Prog Loop Exited: {e:?}"); + return Err(e); + } + Ok(()) + }; + + #[allow(unreachable_code)] + let sftp_loop = async { + loop { + let ch = chan_pipe.receive().await; + + info!("SFTP loop has received a channel handle {:?}", ch.num()); + + // TODO Do some research to find reasonable default buffer lengths + let mut buffer_in = [0u8; 512]; + let mut request_buffer = [0u8; MAX_REQUEST_LEN]; + + match { + let stdio = serv.stdio(ch).await?; + let mut file_server = + DemoSftpServer::::new( + "./demo/sftp/std/testing/out/".to_string(), + ); + + SftpHandler::< + DemoOpaqueFileHandle, + DemoSftpServer, + 512, + >::new(&mut file_server, &mut request_buffer) + .process_loop(stdio, &mut buffer_in) + .await?; + + Ok::<_, Error>(()) + } { + Ok(_) => { + warn!("sftp server loop finished gracefully"); + return Ok(()); + } + Err(e) => { + error!("sftp server loop finished with an error: {}", e); + return Err(e); + } + }; + } + Ok::<_, Error>(()) + }; + + let selected = select(ssh_loop, sftp_loop).await; + match selected { + embassy_futures::select::Either::First(res) => { + warn!("prog_loop finished: {:?}", res); + res + } + embassy_futures::select::Either::Second(res) => { + warn!("sftp_loop finished: {:?}", res); + res + } + } + } +} + +// TODO pool_size should be NUM_LISTENERS but needs a literal +#[embassy_executor::task(pool_size = 4)] +async fn listen( + stack: Stack<'static>, + config: &'static SunsetMutex, +) -> ! { + let demo = StdDemo::default(); + demo_common::listen(stack, config, &demo).await +} + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .format_timestamp_nanos() + .target(env_logger::Target::Stdout) + .init(); + + spawner.spawn(main_task(spawner)).unwrap(); +} diff --git a/demo/sftp/std/tap.sh b/demo/sftp/std/tap.sh new file mode 100755 index 00000000..8732a0cd --- /dev/null +++ b/demo/sftp/std/tap.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# This script generates the tap device that the demo will bind the network stack +# usage `sudo ./tap.sh` + +ip tuntap add name tap0 mode tap user $SUDO_USER group $SUDO_USER +ip addr add 192.168.69.100/24 dev tap0 +ip link set tap0 up diff --git a/demo/sftp/std/testing/extract_txrx.sh b/demo/sftp/std/testing/extract_txrx.sh new file mode 100755 index 00000000..f2c7ab82 --- /dev/null +++ b/demo/sftp/std/testing/extract_txrx.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Find all lines containing SFTP... OR Output Consumer... OR Output Producer... +# and reformat them into a simpler form for further processing. + + +cat < ${1}.txrx +Extracting communications from sunset-demo-sftp-std log file: $1 +Extract of RX (c: Client), TX (s: server), And internal TX (p: pipe producer) +------------------------------------------------ +EOF + +cat $1 | \ +grep -E 'SFTP <---- received: \[|Output Consumer: Bytes written \[|Output Producer: Sending buffer \[' | \ +sed 's/.*received: /c / ; s/.*written /s / ; s/.*Output Producer: Sending buffer /p /' >> ${1}.txrx + + +# Extract received lines. Remove brackets, spaces, +# and split by comma into new lines. Finally remove empty lines. + +# RX +cat $1 | \ +grep -E 'SFTP <---- received: \[' | \ +sed 's/.*received: //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.rx + +# Producer +cat $1 | \ +grep -E 'Output Producer: Sending buffer \[' | \ +sed 's/.*buffer //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.txp + +# TX +cat $1 | \ +grep -E 'Output Consumer: Bytes written \[' | \ +sed 's/.*written //' | \ +sed 's/\[//; s/\]/,/' | \ +tr -d ' ' |tr ',' '\n'| \ +grep -v '^$' > ${1}.tx \ No newline at end of file diff --git a/demo/sftp/std/testing/log_demo_sftp_with_test.sh b/demo/sftp/std/testing/log_demo_sftp_with_test.sh new file mode 100755 index 00000000..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/merge_logs.sh b/demo/sftp/std/testing/merge_logs.sh new file mode 100755 index 00000000..050c05d9 --- /dev/null +++ b/demo/sftp/std/testing/merge_logs.sh @@ -0,0 +1,9 @@ +# merge-logs.sh +# Useful to get events from both client and server logs in chronological order. +# +# usage: ./merge-logs.sh client.log server.log > merged.log + +{ + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)\]/,m){print m[1] "\tC:\t" $0}' "$1" + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)/,m){print m[1] "\tS:\t" $0}' "$2" +} | sort -t $'\t' -k1,1 | cut -f2- \ 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 From 8b585a8d3726b7336d33bca2156940be1a7e21f6 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 5 Mar 2026 12:28:27 +1100 Subject: [PATCH 05/16] [skip ci] Improved demo sftp std testing scripts - Can be run from the repo base folder. Other pwd will fail - All of them will return a value coherent with the test result so they can be used for CI - Improved, but complicating dir listing and stat listing test: require expect and use expect script for the test --- .../std/testing/log_demo_sftp_with_test.sh | 3 + demo/sftp/std/testing/log_get_file_long.sh | 3 + demo/sftp/std/testing/log_get_file_short.sh | 3 + demo/sftp/std/testing/log_get_single_long.sh | 1 - demo/sftp/std/testing/log_get_single_short.sh | 1 - demo/sftp/std/testing/merge_logs.sh | 7 +- demo/sftp/std/testing/out/512B_random | Bin 0 -> 512 bytes demo/sftp/std/testing/test_get.sh | 53 ------ demo/sftp/std/testing/test_get_file_long.sh | 55 ++++++ demo/sftp/std/testing/test_get_file_short.sh | 55 ++++++ demo/sftp/std/testing/test_get_long.sh | 45 ----- demo/sftp/std/testing/test_get_short.sh | 45 ----- .../std/testing/test_long_write_requests.sh | 38 ----- demo/sftp/std/testing/test_ls_dir.sh | 121 ++++++++++++++ demo/sftp/std/testing/test_put_file_long.sh | 62 +++++++ demo/sftp/std/testing/test_put_files.sh | 69 ++++++++ demo/sftp/std/testing/test_read_dir.sh | 33 ---- demo/sftp/std/testing/test_stats.sh | 27 --- demo/sftp/std/testing/test_stats_file.sh | 156 ++++++++++++++++++ demo/sftp/std/testing/test_write_requests.sh | 44 ----- 20 files changed, 531 insertions(+), 290 deletions(-) create mode 100755 demo/sftp/std/testing/log_get_file_long.sh create mode 100755 demo/sftp/std/testing/log_get_file_short.sh delete mode 100755 demo/sftp/std/testing/log_get_single_long.sh delete mode 100755 demo/sftp/std/testing/log_get_single_short.sh create mode 100644 demo/sftp/std/testing/out/512B_random delete mode 100755 demo/sftp/std/testing/test_get.sh create mode 100755 demo/sftp/std/testing/test_get_file_long.sh create mode 100755 demo/sftp/std/testing/test_get_file_short.sh delete mode 100755 demo/sftp/std/testing/test_get_long.sh delete mode 100755 demo/sftp/std/testing/test_get_short.sh delete mode 100755 demo/sftp/std/testing/test_long_write_requests.sh create mode 100755 demo/sftp/std/testing/test_ls_dir.sh create mode 100755 demo/sftp/std/testing/test_put_file_long.sh create mode 100755 demo/sftp/std/testing/test_put_files.sh delete mode 100755 demo/sftp/std/testing/test_read_dir.sh delete mode 100755 demo/sftp/std/testing/test_stats.sh create mode 100755 demo/sftp/std/testing/test_stats_file.sh delete mode 100755 demo/sftp/std/testing/test_write_requests.sh diff --git a/demo/sftp/std/testing/log_demo_sftp_with_test.sh b/demo/sftp/std/testing/log_demo_sftp_with_test.sh index 58768bc9..8e44f352 100755 --- a/demo/sftp/std/testing/log_demo_sftp_with_test.sh +++ b/demo/sftp/std/testing/log_demo_sftp_with_test.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Used to run sftp demo while logging all the interactions with strace and tshark. +# The passed argument is the test file to run, for example: ./test_get_long.sh or ./test_get_short.sh +# This script will be run once the sftp demo is running. TIME_STAMP=$(date +%Y%m%d_%H%M%S) TEST_FILE=$1 diff --git a/demo/sftp/std/testing/log_get_file_long.sh b/demo/sftp/std/testing/log_get_file_long.sh new file mode 100755 index 00000000..a955cb5b --- /dev/null +++ b/demo/sftp/std/testing/log_get_file_long.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./log_demo_sftp_with_test.sh ./test_get_file_long.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/log_get_file_short.sh b/demo/sftp/std/testing/log_get_file_short.sh new file mode 100755 index 00000000..627280c4 --- /dev/null +++ b/demo/sftp/std/testing/log_get_file_short.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./log_demo_sftp_with_test.sh ./test_get_file_short.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/log_get_single_long.sh b/demo/sftp/std/testing/log_get_single_long.sh deleted file mode 100755 index f01b186d..00000000 --- a/demo/sftp/std/testing/log_get_single_long.sh +++ /dev/null @@ -1 +0,0 @@ -./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 deleted file mode 100755 index 58342c33..00000000 --- a/demo/sftp/std/testing/log_get_single_short.sh +++ /dev/null @@ -1 +0,0 @@ -./log_demo_sftp_with_test.sh ./test_get_short.sh \ No newline at end of file diff --git a/demo/sftp/std/testing/merge_logs.sh b/demo/sftp/std/testing/merge_logs.sh index 050c05d9..07b03b5b 100755 --- a/demo/sftp/std/testing/merge_logs.sh +++ b/demo/sftp/std/testing/merge_logs.sh @@ -2,8 +2,9 @@ # Useful to get events from both client and server logs in chronological order. # # usage: ./merge-logs.sh client.log server.log > merged.log - +CLIENT_LOG=$1 +SERVER_LOG=$2 { - awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)\]/,m){print m[1] "\tC:\t" $0}' "$1" - awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)/,m){print m[1] "\tS:\t" $0}' "$2" + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)\]/,m){print m[1] "\tC:\t" $0}' "$CLIENT_LOG" + awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z)/,m){print m[1] "\tS:\t" $0}' "$SERVER_LOG" } | sort -t $'\t' -k1,1 | cut -f2- \ No newline at end of file diff --git a/demo/sftp/std/testing/out/512B_random b/demo/sftp/std/testing/out/512B_random new file mode 100644 index 0000000000000000000000000000000000000000..6c6c281ce6b40a2064ff021dd28c607e97e0a3af GIT binary patch literal 512 zcmV+b0{{K1=A$-%XY#g1if0qpp9VZjuCo`ba{h>q^`+l$rxxu_`GM^@0cjdkS!_N) z$&y1YSospsa4&ONl`hUjB;qBg@>>t!+wu!8JVl%EXa>FSOjE}>*qlU$q7V?8wnh-Y zz#Vbb)f{({xUz<#+H4Jh*GpkX<-n~>7Cs<4x#u)=?5l7%Bek)~cZb_z4>R^1u5cst zWrJd6W$bYV{1u#;1u>}{qNmuR_oxX~7nZ1p`>rAnDLo&0j^r^79Y`ef+*$vPs*Hqa zAwVx3g=<2|76DA9cJ2K^>*bQ>$?k%jA<6AJ&W!yjIVMEyUAVe3B>oKaX60Fw!-?@L z*$0sv`B9oiq(enA@{v+3ZjtLj%_DQUrv+^|B4XEgoxlREZdTGKZrU~JFjndp3>)g2 zIxUN*odzkuuBcetwNjI!GT>zyz`X2uJu3Jh%j-ES7ew-3X>&Eq8xDh7k1^YDm^;Xn z83@*j2czi_&SAckS8&nff-ppyDYI`|j!aAdUPt#P&B9q4OYHw&)*;#_$x+Yv+Vcsp zIH1TeYFT4g!HPTRSC((VrtUr$Fv@(Ve32nzp|~r*%vsfJd)`VkWd@UJNoh|$D2b-T zS}VBr*zFB)!F9A1!i7?FN=cHB3JUitW*cMuK#6qL`}Iw5w@N%V C-1#5? literal 0 HcmV?d00001 diff --git a/demo/sftp/std/testing/test_get.sh b/demo/sftp/std/testing/test_get.sh deleted file mode 100755 index 913e1741..00000000 --- a/demo/sftp/std/testing/test_get.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/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_file_long.sh b/demo/sftp/std/testing/test_get_file_long.sh new file mode 100755 index 00000000..aeb51fb5 --- /dev/null +++ b/demo/sftp/std/testing/test_get_file_long.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests the GET command with a single file of 100MB +# It tests if the downloaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Single long GETs..." + +echo "Cleaning up previous run files" +rm -f -r $BASE_DIR/*_random $REMOTE_DIR/*_random + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + + + +# Generate random data files +echo "Generating random data files..." +# Define test files +FILES=("100MB_random") + +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=100 of=$REMOTE_DIR/100MB_random 2>/dev/null + + +echo "Downloading files..." +sftp -vvvvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} 2>&1 << EOF +$(printf 'get ./%s\n' "${FILES[@]}") +bye +EOF + +echo "DOWNLOAD Test Results:" +echo "=============" +# Test each file +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" + else + echo "Download FAIL: Keeping downloaded and remote files for inspection + ${BASE_DIR}/${file} and ${REMOTE_DIR}/${file}" + fi +done diff --git a/demo/sftp/std/testing/test_get_file_short.sh b/demo/sftp/std/testing/test_get_file_short.sh new file mode 100755 index 00000000..e1cc6b64 --- /dev/null +++ b/demo/sftp/std/testing/test_get_file_short.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests the GET command with a single file of 1MB +# It tests if the downloaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Single short GETs..." + +echo "Cleaning up previous run files" +rm -f -r $BASE_DIR/*_random $REMOTE_DIR/*_random + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + + + +# Generate random data files +echo "Generating random data files..." +# Define test files +FILES=("1MB_random") + +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=1 of=$REMOTE_DIR/1MB_random 2>/dev/null + + +echo "Downloading files..." +sftp -vvvvv -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=DEBUG ${REMOTE_USER}@${REMOTE_HOST} 2>&1 << EOF +$(printf 'get ./%s\n' "${FILES[@]}") +bye +EOF + +echo "DOWNLOAD Test Results:" +echo "=============" +# Test each file +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "Download PASS: ${file}. Cleaning it" + rm -f -r "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" + else + echo "Download FAIL: Keeping downloaded and remote files for inspection + ${BASE_DIR}/${file} and ${REMOTE_DIR}/${file}" + fi +done diff --git a/demo/sftp/std/testing/test_get_long.sh b/demo/sftp/std/testing/test_get_long.sh deleted file mode 100755 index 69c60a04..00000000 --- a/demo/sftp/std/testing/test_get_long.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/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 deleted file mode 100755 index 6d5b5799..00000000 --- a/demo/sftp/std/testing/test_get_short.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/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 deleted file mode 100755 index 71813ede..00000000 --- a/demo/sftp/std/testing/test_long_write_requests.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/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_ls_dir.sh b/demo/sftp/std/testing/test_ls_dir.sh new file mode 100755 index 00000000..a022a2e7 --- /dev/null +++ b/demo/sftp/std/testing/test_ls_dir.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Writes some files in the remote server folder and list them with the LS command +# Run it from the project root directory or testing folder +# This script requires expect tool + +if ! command -v expect >/dev/null 2>&1; then + echo "Error: 'expect' is not installed or not in PATH." + echo "Please install it and run this test again." + exit 1 +fi + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("A_random" "B_random" "D_random" "E_random" "F_random" "G_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$REMOTE_DIR/512B_random 2>/dev/null + +# Generating copies of the test file +echo "Creating copies for each test file..." +for file in "${FILES[@]}"; do + cp $REMOTE_DIR/512B_random "$REMOTE_DIR/${file}" +done + +rm $REMOTE_DIR/512B_random + +echo "Files created in remote folder ($REMOTE_DIR):" +echo "=============" +ls -l $REMOTE_DIR +echo "" + +# Using expect to automate the sftp session and list the files in the remote folder +# Comparing them to the expected files list + + +echo "Checking that the filenames are present" +echo "==============" + + +FILES_STR="${FILES[*]}" +export FILES_STR REMOTE_HOST REMOTE_USER +expect << 'EOF' +set timeout 20 + +spawn sftp -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $env(REMOTE_USER)@$env(REMOTE_HOST) + +# Wait for sftp> prompt +expect { + -re {(?m)^sftp> ?$} {} + -re {(?i)password:} { + puts "ERROR: password prompt received" + exit 1 + } + -re {.+\n} { exp_continue } + timeout { + puts "ERROR: did not receive sftp prompt" + exit 1 + } + eof { + puts "ERROR: sftp terminated before showing prompt" + exit 1 + } +} + +send -- "ls -1\r" +expect { + -re {(?ms)(.*)\r?\nsftp> ?$} { + set ls_output $expect_out(1,string) + } + timeout { + puts "ERROR: did not receive prompt after ls" + exit 1 + } + eof { + puts "ERROR: sftp terminated after ls" + exit 1 + } +} +# Normalize CRLF -> LF for reliable matching +regsub -all {\r} $ls_output "" ls_output + +set expected_files [split $env(FILES_STR) " "] +foreach f $expected_files { + if {![regexp -line -- "^$f$" $ls_output]} { + puts "ERROR: missing file: $f" + exit 1 + } +} +send -- "bye\r" +expect eof +EOF +EXPECT_RESULT=$? + +echo "Cleaning up local files..." +rm -f -r $REMOTE_DIR/*_random + +if [ "$EXPECT_RESULT" -ne 0 ]; then + echo "SFTP connection test failed" + exit 1 +else + echo "SFTP connection test passed: all expected files are present" + + exit 0 +fi + + diff --git a/demo/sftp/std/testing/test_put_file_long.sh b/demo/sftp/std/testing/test_put_file_long.sh new file mode 100755 index 00000000..ac2879f0 --- /dev/null +++ b/demo/sftp/std/testing/test_put_file_long.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Test PUT a long file of 100MB +# It tests if the uploaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Cleaning the remote directory +rm -f -r $REMOTE_DIR/* + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("100MB_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=1048576 count=100 of=$BASE_DIR/100MB_random 2>/dev/null + + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} << EOF +$(printf "put $BASE_DIR/%s\n" "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +DIFF_RESULT=0 +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + rm -f -r "$BASE_DIR"/${file} "$REMOTE_DIR"/${file} + else + ((DIFF_RESULT++)) + echo "FAIL: ${file}" + fi +done + +if [ "$DIFF_RESULT" -ne 0 ]; then + echo "$DIFF_RESULT files failed: Keeping file(s) for inspection" + exit "$DIFF_RESULT" +else + echo "Upload test Passed." + exit 0 +fi + diff --git a/demo/sftp/std/testing/test_put_files.sh b/demo/sftp/std/testing/test_put_files.sh new file mode 100755 index 00000000..a7074e2d --- /dev/null +++ b/demo/sftp/std/testing/test_put_files.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Test PUT a small files onto the server +# It tests if the uploaded file is the same as the original one (diff) +# Run it from the project root directory or testing folder + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +# Cleaning the remote directory +rm -f -r $REMOTE_DIR/* + + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("512B_random" "16kB_random" "64kB_random" "65kB_random" "256kB_random" "1024kB_random" "2048kB_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$BASE_DIR/512B_random 2>/dev/null +dd if=/dev/random bs=1024 count=16 of=$BASE_DIR/16kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=64 of=$BASE_DIR/64kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=65 of=$BASE_DIR/65kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=256 of=$BASE_DIR/256kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=1024 of=$BASE_DIR/1024kB_random 2>/dev/null +dd if=/dev/random bs=1024 count=2048 of=$BASE_DIR/2048kB_random 2>/dev/null + + +echo "Uploading files to ${REMOTE_USER}@${REMOTE_HOST}..." + +# Upload all files +sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} -vvv << EOF +$(printf "put $BASE_DIR/%s\n" "${FILES[@]}") +bye +EOF + +echo "Test Results:" +echo "=============" + +# Test each file +DIFF_RESULT=0 +for file in "${FILES[@]}"; do + if diff "$BASE_DIR/${file}" "$REMOTE_DIR/${file}" >/dev/null 2>&1; then + echo "PASS: ${file}" + rm -f -r "$BASE_DIR"/${file} "$REMOTE_DIR"/${file} + else + ((DIFF_RESULT++)) + echo "FAIL: ${file}" + fi +done + +if [ "$DIFF_RESULT" -ne 0 ]; then + echo "$DIFF_RESULT files failed: Keeping file(s) for inspection" + exit "$DIFF_RESULT" +else + echo "Upload test Passed." + exit 0 +fi + diff --git a/demo/sftp/std/testing/test_read_dir.sh b/demo/sftp/std/testing/test_read_dir.sh deleted file mode 100755 index ec2f18d3..00000000 --- a/demo/sftp/std/testing/test_read_dir.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/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 deleted file mode 100755 index a5c2ceb5..00000000 --- a/demo/sftp/std/testing/test_stats.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/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_stats_file.sh b/demo/sftp/std/testing/test_stats_file.sh new file mode 100755 index 00000000..6673b019 --- /dev/null +++ b/demo/sftp/std/testing/test_stats_file.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# This test checks the stats of a single file +# Run it from the project root directory or testing folder +# This script requires expect tool + +if ! command -v expect >/dev/null 2>&1; then + echo "Error: 'expect' is not installed or not in PATH." + echo "Please install it and run this test again." + exit 1 +fi + +BASE_DIR=$(pwd) + +if [ -f "Cargo.toml" ]; then + REMOTE_DIR=$BASE_DIR"/demo/sftp/std/testing/out" +elif [[ "$BASE_DIR" == *"/testing"* ]]; then + REMOTE_DIR=$BASE_DIR"/out" +else + echo "Please run this script from the project root or from the testing folder" + exit 1 +fi + +echo "Testing Stats..." + +# Set remote server details +REMOTE_HOST="192.168.69.2" +REMOTE_USER="any" + +# Define test files +FILES=("512B_random") + +# Generate random data files +echo "Generating random data files..." +dd if=/dev/random bs=512 count=1 of=$REMOTE_DIR/512B_random 2>/dev/null + +# # List files +# sftp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${REMOTE_USER}@${REMOTE_HOST} << EOF +# $(printf 'ls -l %s\n' "${FILES[@]} | awk '{print $1, $9}'") +# bye +# EOF + +FILES_STR="${FILES[*]}" + +export REMOTE_HOST REMOTE_USER FILES_STR +expect << 'EOF' +set timeout 20 + +spawn sftp -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $env(REMOTE_USER)@$env(REMOTE_HOST) + +# Wait for sftp> prompt +expect { + -re {(?m)^sftp> ?$} {} + -re {(?i)password:} { + puts "ERROR: password prompt received" + exit 1 + } + -re {.+\n} { exp_continue } + timeout { + puts "ERROR: did not receive sftp prompt" + exit 1 + } + eof { + puts "ERROR: sftp terminated before showing prompt" + exit 1 + } +} + +send -- "ls -ln\r" +expect { + -re {(?ms)(.*)\r?\nsftp> ?$} { + set ls_output $expect_out(0,string) + } + timeout { + puts "ERROR: did not receive prompt after ls" + exit 1 + } + eof { + puts "ERROR: sftp terminated after ls" + exit 1 + } +} + +# Normalize CRLF -> LF +regsub -all {\r} $ls_output "" ls_output + +# Hardcoded expected values. If +set expected_name "512B_random" +set expected_perm "-rw-rw-r--" +set expected_uid "1000" +set expected_gid "1000" +set expected_size "512" + +set found 0 +foreach line [split $ls_output "\n"] { + set line [string trim $line] + if {$line eq ""} { continue } + if {[string match "ls -ln*" $line]} { continue } ;# echoed command + if {[string match "sftp>*" $line]} { continue } ;# prompt + if {[string match "total *" $line]} { continue } ;# ls header + + puts "Good candidate: <$line>" + + # Split into non-space fields: + # perms links uid gid size month day time-or-year name + set fields [regexp -all -inline {\S+} $line] + if {[llength $fields] < 9} { + puts "Skip: not enough fields: <$line>" + continue + } + + set perm [lindex $fields 0] + set uid [lindex $fields 2] + set gid [lindex $fields 3] + set size [lindex $fields 4] + set name [lindex $fields end] + + puts "Parsed: perm=$perm uid=$uid gid=$gid size=$size name=$name" + + if {$name ne $expected_name} { + puts "Skip: different filename: <$line>" + continue + } + + set found 1 + + if {$perm ne $expected_perm || $uid ne $expected_uid || $gid ne $expected_gid || $size ne $expected_size} { + puts "ERROR: stat mismatch for $expected_name" + puts " expected: perm=$expected_perm uid=$expected_uid gid=$expected_gid size=$expected_size" + puts " actual: perm=$perm uid=$uid gid=$gid size=$size" + exit 1 + } +} + +if {!$found} { + puts "ERROR: file $expected_name not found in ls output" + exit 1 +} else { + puts "Stats test passed: file $expected_name has expected permissions, ownership and size" + exit 0 +} + +send -- "bye\r" +expect eof +EOF +EXPECT_RESULT=$? + +if [ "$EXPECT_RESULT" -ne 0 ]; then + echo "SFTP stats test failed" + exit 1 +else + echo "SFTP stats test passed" + exit 0 +fi + +echo "Cleaning up local files..." +rm -f -r $REMOTE_DIR/*_random \ No newline at end of file diff --git a/demo/sftp/std/testing/test_write_requests.sh b/demo/sftp/std/testing/test_write_requests.sh deleted file mode 100755 index cabab6b2..00000000 --- a/demo/sftp/std/testing/test_write_requests.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/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 From 8ef777f1dfa45f8622d4b0906b4cfb99b080121c Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 5 Mar 2026 12:29:12 +1100 Subject: [PATCH 06/16] CI updated --- testing/ci.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/testing/ci.sh b/testing/ci.sh index 162df28a..49b56766 100755 --- a/testing/ci.sh +++ b/testing/ci.sh @@ -3,7 +3,7 @@ set -v set -e -export CARGO_TARGET_DIR=target/ci +export CARGO_TARGET_DIR=testing/target # Set OFFLINE=1 to avoid rustup. cargo might still run offline. @@ -74,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 From 52591797817a3c88caed97e5d7a90e5e11421ad5 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 5 Mar 2026 15:17:33 +1100 Subject: [PATCH 07/16] CI fix: cargo fmt Should have run testing/ci.sh beforehand --- sftp/src/proto.rs | 2 +- sftp/src/sftpsource.rs | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/sftp/src/proto.rs b/sftp/src/proto.rs index 7b1857b8..ee61edb4 100644 --- a/sftp/src/proto.rs +++ b/sftp/src/proto.rs @@ -89,7 +89,7 @@ impl<'a> From<&'a str> for Filename<'a> { impl<'a> Filename<'a> { /// pub fn as_str(&self) -> Result<&'a str, WireError> { - core::str::from_utf8(self.0 .0).map_err(|_| WireError::BadString) + core::str::from_utf8(self.0.0).map_err(|_| WireError::BadString) } } diff --git a/sftp/src/sftpsource.rs b/sftp/src/sftpsource.rs index 837ee0f1..26d28081 100644 --- a/sftp/src/sftpsource.rs +++ b/sftp/src/sftpsource.rs @@ -1,6 +1,6 @@ use crate::proto::{ - SftpNum, SFTP_FIELD_ID_INDEX, SFTP_FIELD_LEN_INDEX, SFTP_FIELD_LEN_LENGTH, - SFTP_FIELD_REQ_ID_INDEX, SFTP_FIELD_REQ_ID_LEN, + 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}; @@ -26,9 +26,7 @@ impl<'de> SSHSource<'de> for SftpSource<'de> { self.index += len; trace!( "slice returned: {:?}. original index {:?}, new index: {:?}", - slice, - original_index, - self.index + slice, original_index, self.index ); Ok(slice) } From 19f63d489ecc67ff8666205aaf2fdc069495908f Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Tue, 10 Mar 2026 15:28:53 +1100 Subject: [PATCH 08/16] Addressing easier some points in the review Thanks for the review, you have risen some good points. I am going to continue addressing your review, for now these are my changes: - removed default = [] as it is unnecessary - warn->debug for From request_packet_type for SftpPacket - requestholder.rs::RequestHolder.valid_request() : explicit None on Err() try_get_ref() - SftpServer.rs::SftpServe.stats()->attrs() and uses replaced - sftpsource.rs::SftpSource.peak_packet_type()->peek_packet_type() - ci.sh undo revert from [342a515](https://github.com/mkj/sunset/commit/342a5156475c9934f7f157382af16eec369c1c47) and now builds `demo/sftp/std` without release or bloat --- sftp/Cargo.toml | 1 - sftp/src/proto.rs | 4 ++-- sftp/src/sftphandler/requestholder.rs | 17 ++++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml index 23e4511a..4822179b 100644 --- a/sftp/Cargo.toml +++ b/sftp/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.2" edition = "2024" [features] -default = [] # long paths support, which allows paths up to 4096 bytes, by default paths are limited to 256 bytes long-paths-4096 = [] long-paths-1024 = [] diff --git a/sftp/src/proto.rs b/sftp/src/proto.rs index ee61edb4..6ea55e0e 100644 --- a/sftp/src/proto.rs +++ b/sftp/src/proto.rs @@ -936,7 +936,7 @@ macro_rules! sftpmessages { /// **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); + debug!("Casting from {:?} to SftpPacket cannot set Request Id",$request_ssh_fxp_name); SftpPacket::$request_packet_variant(ReqId(0), s) } } @@ -945,7 +945,7 @@ macro_rules! sftpmessages { /// **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); + debug!("Casting from {:?} to SftpPacket cannot set Request Id",$response_ssh_fxp_name); SftpPacket::$response_packet_variant(ReqId(0), s) } } diff --git a/sftp/src/sftphandler/requestholder.rs b/sftp/src/sftphandler/requestholder.rs index e962118f..e4a80172 100644 --- a/sftp/src/sftphandler/requestholder.rs +++ b/sftp/src/sftphandler/requestholder.rs @@ -205,13 +205,16 @@ impl<'a> RequestHolder<'a> { 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, - } + let Ok(buffer_ref) = self.try_get_ref() else { + return None; + }; + + let mut source = SftpSource::new(buffer_ref); + + let Ok(request) = SftpPacket::decode_request(&mut source) else { + return None; + }; + return Some(request); } /// Gets a reference to the slice that it is holding From e8cab8c95a4d45a6f7c8ef3467b2fd66ffbfc8af Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Tue, 24 Mar 2026 15:42:14 +1100 Subject: [PATCH 09/16] Fixing missing points in previous commit These should have been added to the previous commit but I did not track the changes: - ci.sh: Changing back teh target folder - sftpserver.rs: Renaming stats to attrs trait and changing all related implementations and calls - sftpsource.rs: Fixing typo peak-> peek in file and related calls --- demo/sftp/std/src/demosftpserver.rs | 2 +- sftp/src/sftphandler/requestholder.rs | 2 +- sftp/src/sftphandler/sftphandler.rs | 4 ++-- sftp/src/sftpserver.rs | 4 ++-- sftp/src/sftpsource.rs | 6 +++--- testing/ci.sh | 8 +++----- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/demo/sftp/std/src/demosftpserver.rs b/demo/sftp/std/src/demosftpserver.rs index 2a703bce..7bc7dbce 100644 --- a/demo/sftp/std/src/demosftpserver.rs +++ b/demo/sftp/std/src/demosftpserver.rs @@ -415,7 +415,7 @@ impl SftpServer<'_, OFH> for DemoSftpServer { } } - async fn stats( + async fn attrs( &mut self, follow_links: bool, file_path: &str, diff --git a/sftp/src/sftphandler/requestholder.rs b/sftp/src/sftphandler/requestholder.rs index e4a80172..fe4ac47f 100644 --- a/sftp/src/sftphandler/requestholder.rs +++ b/sftp/src/sftphandler/requestholder.rs @@ -169,7 +169,7 @@ impl<'a> RequestHolder<'a> { 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 let Ok(pt) = source.peek_packet_type() { if !pt.is_request() { error!("The request candidate is not a request: {pt:?}"); return Err(RequestHolderError::NotRequest); diff --git a/sftp/src/sftphandler/sftphandler.rs b/sftp/src/sftphandler/sftphandler.rs index 50500a50..339e61a2 100644 --- a/sftp/src/sftphandler/sftphandler.rs +++ b/sftp/src/sftphandler/sftphandler.rs @@ -483,7 +483,7 @@ where SftpPacket::LStat(req_id, LStat { file_path: path }) => { match self .file_server - .stats(false, path.as_str()?) + .attrs(false, path.as_str()?) .await { Ok(attrs) => { @@ -517,7 +517,7 @@ where SftpPacket::Stat(req_id, Stat { file_path: path }) => { match self .file_server - .stats(true, path.as_str()?) + .attrs(true, path.as_str()?) .await { Ok(attrs) => { diff --git a/sftp/src/sftpserver.rs b/sftp/src/sftpserver.rs index 35019f55..5942fa80 100644 --- a/sftp/src/sftpserver.rs +++ b/sftp/src/sftpserver.rs @@ -193,8 +193,8 @@ where } } - /// Provides the stats of the given file path - fn stats( + /// Provides the attributes of the given file path + fn attrs( &mut self, follow_links: bool, file_path: &str, diff --git a/sftp/src/sftpsource.rs b/sftp/src/sftpsource.rs index 26d28081..69c9699e 100644 --- a/sftp/src/sftpsource.rs +++ b/sftp/src/sftpsource.rs @@ -46,7 +46,7 @@ impl<'de> SftpSource<'de> { debug!("New source with content: : {:?}", buffer); SftpSource { buffer: buffer, index: 0 } } - /// Peaks the buffer for packet type [`SftpNum`]. This does not advance + /// Peeks the buffer for packet type [`SftpNum`]. This does not advance /// the reading index /// /// Useful to observe the packet fields in special conditions where a @@ -54,7 +54,7 @@ impl<'de> SftpSource<'de> { /// /// **Warning**: will only work in well formed packets, in other case /// the result will contains garbage - pub(crate) fn peak_packet_type(&self) -> WireResult { + pub(crate) fn peek_packet_type(&self) -> WireResult { if self.buffer.len() <= SFTP_FIELD_ID_INDEX { debug!( "Peak packet type failed: buffer len <= SFTP_FIELD_ID_INDEX ( {:?} <= {:?})", @@ -191,7 +191,7 @@ mod local_tests { fn peaking_type() { let buffer_status = status_buffer(); let source = SftpSource::new(&buffer_status); - let read_packet_type = source.peak_packet_type().unwrap(); + let read_packet_type = source.peek_packet_type().unwrap(); let original_packet_type = SftpNum::from(101u8); assert_eq!(original_packet_type, read_packet_type); } diff --git a/testing/ci.sh b/testing/ci.sh index 49b56766..0c17df96 100755 --- a/testing/ci.sh +++ b/testing/ci.sh @@ -3,7 +3,7 @@ set -v set -e -export CARGO_TARGET_DIR=testing/target +export CARGO_TARGET_DIR=target/ci # Set OFFLINE=1 to avoid rustup. cargo might still run offline. @@ -76,10 +76,8 @@ size target/thumbv6m-none-eabi/release/sunset-demo-picow | tee "$OUT/picow-size. ( 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" +cargo build +cargo test ) size ./target/release/sunset-demo-sftp-std | tee "$OUT/sftp-std-size.txt" From 6be8a2cc6bc3aefed789a1dc304faab77a8b231d Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Tue, 24 Mar 2026 15:44:08 +1100 Subject: [PATCH 10/16] removing size from demo/sftp/std build outputs --- testing/ci.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/testing/ci.sh b/testing/ci.sh index 0c17df96..4b01fe79 100755 --- a/testing/ci.sh +++ b/testing/ci.sh @@ -79,7 +79,6 @@ cd demo/sftp/std cargo build cargo test ) -size ./target/release/sunset-demo-sftp-std | tee "$OUT/sftp-std-size.txt" ( cd fuzz From 7aaa7de30cb5f84a3465ce97ef26202e4add6f3a Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Tue, 24 Mar 2026 16:00:11 +1100 Subject: [PATCH 11/16] Fixing unnecessary duplicated lifetimes and tidying up - [x] All tests in testing passing - [ ] Deleting forgotten 512B_random file from the testing output directory --- demo/sftp/std/testing/out/512B_random | Bin 512 -> 0 bytes sftp/src/proto.rs | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 demo/sftp/std/testing/out/512B_random diff --git a/demo/sftp/std/testing/out/512B_random b/demo/sftp/std/testing/out/512B_random deleted file mode 100644 index 6c6c281ce6b40a2064ff021dd28c607e97e0a3af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 512 zcmV+b0{{K1=A$-%XY#g1if0qpp9VZjuCo`ba{h>q^`+l$rxxu_`GM^@0cjdkS!_N) z$&y1YSospsa4&ONl`hUjB;qBg@>>t!+wu!8JVl%EXa>FSOjE}>*qlU$q7V?8wnh-Y zz#Vbb)f{({xUz<#+H4Jh*GpkX<-n~>7Cs<4x#u)=?5l7%Bek)~cZb_z4>R^1u5cst zWrJd6W$bYV{1u#;1u>}{qNmuR_oxX~7nZ1p`>rAnDLo&0j^r^79Y`ef+*$vPs*Hqa zAwVx3g=<2|76DA9cJ2K^>*bQ>$?k%jA<6AJ&W!yjIVMEyUAVe3B>oKaX60Fw!-?@L z*$0sv`B9oiq(enA@{v+3ZjtLj%_DQUrv+^|B4XEgoxlREZdTGKZrU~JFjndp3>)g2 zIxUN*odzkuuBcetwNjI!GT>zyz`X2uJu3Jh%j-ES7ew-3X>&Eq8xDh7k1^YDm^;Xn z83@*j2czi_&SAckS8&nff-ppyDYI`|j!aAdUPt#P&B9q4OYHw&)*;#_$x+Yv+Vcsp zIH1TeYFT4g!HPTRSC((VrtUr$Fv@(Ve32nzp|~r*%vsfJd)`VkWd@UJNoh|$D2b-T zS}VBr*zFB)!F9A1!i7?FN=cHB3JUitW*cMuK#6qL`}Iw5w@N%V C-1#5? diff --git a/sftp/src/proto.rs b/sftp/src/proto.rs index 6ea55e0e..404b179f 100644 --- a/sftp/src/proto.rs +++ b/sftp/src/proto.rs @@ -733,11 +733,10 @@ macro_rules! sftpmessages { paste!{ - impl<'a: 'de, 'de> SSHDecode<'de> for SftpPacket<'a> - where 'de: 'a // This implies that both lifetimes are equal + impl<'a> SSHDecode<'a> for SftpPacket<'a> { fn dec(s: &mut S) -> WireResult - where S: SSHSource<'de> { + where S: SSHSource<'a> { let packet_type_number = u8::dec(s)?; let packet_type = SftpNum::from(packet_type_number); From 74ff19afd1a5cb272c2d2849b33c8346d009983f Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 26 Mar 2026 09:29:35 +1100 Subject: [PATCH 12/16] Reverting changes to sshwire-derive/src/lib.rs Addressing needed changes in proto.rs. I looked at the code generated by the macro before reverting lib.rs (cargo-expand expand) and applied equivalent code. --- sftp/src/proto.rs | 37 ++++++++++++++++++++++++++++++------- sshwire-derive/src/lib.rs | 7 ++++++- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/sftp/src/proto.rs b/sftp/src/proto.rs index 404b179f..e53eb0af 100644 --- a/sftp/src/proto.rs +++ b/sftp/src/proto.rs @@ -1,8 +1,8 @@ use crate::sftpsource::SftpSource; use sunset::sshwire::{ - BinString, SSHDecode, SSHEncode, SSHSink, SSHSource, TextString, WireError, - WireResult, + BinString, SSHDecode, SSHEncode, SSHEncodeEnum, SSHSink, SSHSource, TextString, + WireError, WireResult, }; use sunset_sshwire_derive::{SSHDecode, SSHEncode}; @@ -604,31 +604,54 @@ macro_rules! sftpmessages { ) => { paste! { /// Represent a subset of the SFTP packet types defined by draft-ietf-secsh-filexfer-02 - #[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, SSHEncode)] + #[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] #[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), } + impl SSHEncode for SftpNum { + fn enc(&self, _: &mut dyn SSHSink) -> WireResult<()> { + Ok(()) + } + } + + impl SSHEncodeEnum for SftpNum { + fn variant_name(&self) -> WireResult<&'static str> { + let r = match self { + $( + Self::[<$init_ssh_fxp_name:upper>] => $init_ssh_fxp_name, + )* + $( + Self::[<$request_ssh_fxp_name:upper>] => $request_ssh_fxp_name, + )* + $( + Self::[<$response_ssh_fxp_name:upper>] => $response_ssh_fxp_name, + )* + Self::Other(_) => { + return Err(WireError::UnknownVariant); + } + }; + #[allow(unreachable_code)] Ok(r) + } + } + } // paste + impl<'de> SSHDecode<'de> for SftpNum { fn dec(s: &mut S) -> WireResult where diff --git a/sshwire-derive/src/lib.rs b/sshwire-derive/src/lib.rs index 462ca224..fed01188 100644 --- a/sshwire-derive/src/lib.rs +++ b/sshwire-derive/src/lib.rs @@ -283,6 +283,11 @@ fn encode_enum( let atts = take_field_atts(&var.attributes)?; let mut rhs = StreamBuilder::new(); + if let Some(val) = &var.value { + // Avoid users expecting enum values to be encoded. + // Could be implemented if needed. + return Err(Error::Custom { error: "sunset_sshwire_derive::SSHEncode currently does not encode enum discriminants.".into(), span: Some(val.span())}) + } match var.fields { None => { // Unit enum @@ -300,7 +305,7 @@ fn encode_enum( } } - _ => return Err(Error::Custom { error: "SSHEncode currently only implements Unit or single value enum variants.".into(), span: None}) + _ => return Err(Error::Custom { error: "sunset_sshwire_derive::SSHEncode currently only implements Unit or single value enum variants.".into(), span: None}) } match_arm.puncts("=>"); From 91c3763d425c5e5c35a281f073ba0793ff52eddf Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Fri, 27 Mar 2026 10:00:55 +1100 Subject: [PATCH 13/16] Removing new(&str) from `OpaqueFileHandle` As pointed out by @mkj, the new(& str) method in `OpaqueFileHandle` is brittle. I added it mindlessly by the DemoFileHandleManager implementation. Now I replaced the `new(&str)`by adding the condition to whoever decides to use `FileHandleManager` trait to implement `InitWithSeed` + `OpaqueFileHandle` for the key. --- demo/sftp/std/src/demofilehandlemanager.rs | 19 +++++++++------- demo/sftp/std/src/demoopaquefilehandle.rs | 22 ++++++++++++------ demo/sftp/std/src/demosftpserver.rs | 13 +++++++---- sftp/src/lib.rs | 1 + sftp/src/opaquefilehandle.rs | 26 ++++++++++++---------- 5 files changed, 50 insertions(+), 31 deletions(-) diff --git a/demo/sftp/std/src/demofilehandlemanager.rs b/demo/sftp/std/src/demofilehandlemanager.rs index 6b1cb278..b1c649ba 100644 --- a/demo/sftp/std/src/demofilehandlemanager.rs +++ b/demo/sftp/std/src/demofilehandlemanager.rs @@ -1,11 +1,13 @@ -use sunset_sftp::handles::{OpaqueFileHandle, OpaqueFileHandleManager, PathFinder}; +use sunset_sftp::handles::{ + InitWithSeed, OpaqueFileHandle, OpaqueFileHandleManager, PathFinder, +}; use sunset_sftp::protocol::StatusCode; use std::collections::HashMap; // Not enforced. Only for std. For no_std environments other solutions can be used to store Key, Value pub struct DemoFileHandleManager where - K: OpaqueFileHandle, + K: OpaqueFileHandle + InitWithSeed, V: PathFinder, { handle_map: HashMap, @@ -13,7 +15,7 @@ where impl DemoFileHandleManager where - K: OpaqueFileHandle, + K: OpaqueFileHandle + InitWithSeed, V: PathFinder, { pub fn new() -> Self { @@ -23,12 +25,12 @@ where impl OpaqueFileHandleManager for DemoFileHandleManager where - K: OpaqueFileHandle, + K: OpaqueFileHandle + InitWithSeed, V: PathFinder, { - type Error = StatusCode; + type Err = StatusCode; - fn insert(&mut self, private_handle: V, salt: &str) -> Result { + fn insert(&mut self, private_handle: V, salt: &str) -> Result { if self .handle_map .iter() @@ -37,9 +39,10 @@ where return Err(StatusCode::SSH_FX_PERMISSION_DENIED); } - let handle = K::new( + let handle = K::init_with_seed( format!("{:}-{:}", &private_handle.get_path_ref(), salt).as_str(), - ); + ) + .map_err(|_| StatusCode::SSH_FX_FAILURE)?; self.handle_map.insert(handle.clone(), private_handle); Ok(handle) diff --git a/demo/sftp/std/src/demoopaquefilehandle.rs b/demo/sftp/std/src/demoopaquefilehandle.rs index 67c2fc6b..e5bad21b 100644 --- a/demo/sftp/std/src/demoopaquefilehandle.rs +++ b/demo/sftp/std/src/demoopaquefilehandle.rs @@ -1,4 +1,4 @@ -use sunset_sftp::handles::OpaqueFileHandle; +use sunset_sftp::handles::{InitWithSeed, OpaqueFileHandle}; use sunset_sftp::protocol::FileHandle; use sunset::sshwire::{BinString, WireError}; @@ -14,12 +14,6 @@ pub(crate) struct DemoOpaqueFileHandle { } 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::()) { @@ -35,3 +29,17 @@ impl OpaqueFileHandle for DemoOpaqueFileHandle { FileHandle(BinString(&self.tiny_hash)) } } + +/// Implemented to allow the use of `DemoOpaqueFileHandle` as a key in the `OpaqueHandleManager` +impl InitWithSeed for DemoOpaqueFileHandle { + type Err = WireError; + + fn init_with_seed(seed: &str) -> Result { + let mut hasher = FnvHasher::default(); + hasher.write(seed.as_bytes()); + + Ok(DemoOpaqueFileHandle { + tiny_hash: (hasher.finish() as u32).to_be_bytes(), + }) + } +} diff --git a/demo/sftp/std/src/demosftpserver.rs b/demo/sftp/std/src/demosftpserver.rs index 7bc7dbce..60cad253 100644 --- a/demo/sftp/std/src/demosftpserver.rs +++ b/demo/sftp/std/src/demosftpserver.rs @@ -1,7 +1,9 @@ use crate::demofilehandlemanager::DemoFileHandleManager; use sunset_sftp::error::SftpResult; -use sunset_sftp::handles::{OpaqueFileHandle, OpaqueFileHandleManager, PathFinder}; +use sunset_sftp::handles::{ + InitWithSeed, OpaqueFileHandle, OpaqueFileHandleManager, PathFinder, +}; use sunset_sftp::protocol::{Attrs, Filename, NameEntry, PFlags, StatusCode}; use sunset_sftp::server::helpers::DirEntriesCollection; use sunset_sftp::server::{ @@ -10,6 +12,7 @@ use sunset_sftp::server::{ #[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}; @@ -92,12 +95,12 @@ impl PathFinder for PrivateDirHandle { } /// A basic demo server. Used as a demo and to test SFTP functionality -pub struct DemoSftpServer { +pub struct DemoSftpServer { base_path: String, handles_manager: DemoFileHandleManager, } -impl DemoSftpServer { +impl DemoSftpServer { pub fn new(base_path: String) -> Self { if !Path::new(&base_path).exists() { debug!("Base path {:?} does not exist. Creating it", base_path); @@ -112,7 +115,9 @@ impl DemoSftpServer { } } -impl SftpServer<'_, OFH> for DemoSftpServer { +impl SftpServer<'_, OFH> + for DemoSftpServer +{ async fn open(&mut self, filename: &str, mode: &PFlags) -> SftpOpResult { debug!("Open file: filename = {:?}, mode = {:?}", filename, mode); diff --git a/sftp/src/lib.rs b/sftp/src/lib.rs index f6cd7a3d..e7158172 100644 --- a/sftp/src/lib.rs +++ b/sftp/src/lib.rs @@ -100,6 +100,7 @@ pub mod server { /// Handles and helpers used by the [`sftpserver::SftpServer`] trait implementer pub mod handles { + pub use crate::opaquefilehandle::InitWithSeed; pub use crate::opaquefilehandle::OpaqueFileHandle; pub use crate::opaquefilehandle::OpaqueFileHandleManager; pub use crate::opaquefilehandle::PathFinder; diff --git a/sftp/src/opaquefilehandle.rs b/sftp/src/opaquefilehandle.rs index 19450ef1..cde4aa7d 100644 --- a/sftp/src/opaquefilehandle.rs +++ b/sftp/src/opaquefilehandle.rs @@ -7,10 +7,6 @@ use sunset::sshwire::WireResult; 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; @@ -29,7 +25,17 @@ pub trait PathFinder { fn get_path_ref(&self) -> &str; } -/// This trait is used to manage the OpaqueFile +/// Used in the `OpaqueFileHandleManager` to generate a Key (OpaqueFileHandle) from a seed +pub trait InitWithSeed: Sized { + /// The error type used for the implementation of `init_with_seed` useful to harmonize the error handling of the `OpaqueFileHandleManager` implementation + type Err; + + /// Creates a new instance using a given string slice as `seed` which + /// content should not clearly related to the seed + fn init_with_seed(s: &str) -> Result; +} + +/// This trait is used to manage the OpaqueFileHandles (K) together with the private handle (V) that contains the details of the file internally stored in the system /// /// The SFTP module user is not required to use it but instead is a suggestion for an exchangeable /// trait that facilitates structuring the store and retrieve of 'OpaqueFileHandleTrait' (K), @@ -39,22 +45,18 @@ pub trait PathFinder { /// to look for the file path. pub trait OpaqueFileHandleManager where - K: OpaqueFileHandle, + K: OpaqueFileHandle + InitWithSeed, 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; + type Err; /// Given the private_handle, stores it and return an opaque file handle /// /// Returns an error if the private_handle has a matching path as obtained from `PathFinder` /// /// Salt has been added to allow the user to add a factor that will mask how the opaque handle is generated - fn insert(&mut self, private_handle: V, salt: &str) -> Result; + fn insert(&mut self, private_handle: V, salt: &str) -> Result; /// fn remove(&mut self, opaque_handle: &K) -> Option; From 1feecff1c78741d923cd0a5c51a885c8f0e93595 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Sat, 4 Apr 2026 12:34:16 +1100 Subject: [PATCH 14/16] RUSTSEC-2024-0436: Fixing Paste to version 1.0.25 --- sftp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml index 4822179b..a829b6e0 100644 --- a/sftp/Cargo.toml +++ b/sftp/Cargo.toml @@ -19,7 +19,7 @@ sunset-sshwire-derive = { path = "../sshwire-derive" } embedded-io-async = "0.6" num_enum = { version = "0.7.4", default-features = false } -paste = "1.0" +paste = "1.25.0" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment log = "0.4" embassy-sync = "0.7.2" embassy-futures = "0.1.2" From 635b1737f84a1b3a9143d02eade4ce1207dc01b2 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Sat, 4 Apr 2026 12:38:27 +1100 Subject: [PATCH 15/16] Fixing typo in previous commit --- sftp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml index a829b6e0..dc9c66d6 100644 --- a/sftp/Cargo.toml +++ b/sftp/Cargo.toml @@ -19,7 +19,7 @@ sunset-sshwire-derive = { path = "../sshwire-derive" } embedded-io-async = "0.6" num_enum = { version = "0.7.4", default-features = false } -paste = "1.25.0" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment +paste = "1.0.25" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment log = "0.4" embassy-sync = "0.7.2" embassy-futures = "0.1.2" From 45f54ff2d58b81847643dd45be9dda0cc56ffa12 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Sat, 4 Apr 2026 12:46:14 +1100 Subject: [PATCH 16/16] Extra typo. Running CI now to make sure that all is good Sorry about the noise --- Cargo.lock | 4 ++-- sftp/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 534121f7..6629141a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1925,9 +1925,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pem-rfc7468" diff --git a/sftp/Cargo.toml b/sftp/Cargo.toml index dc9c66d6..fe4e834e 100644 --- a/sftp/Cargo.toml +++ b/sftp/Cargo.toml @@ -19,7 +19,7 @@ sunset-sshwire-derive = { path = "../sshwire-derive" } embedded-io-async = "0.6" num_enum = { version = "0.7.4", default-features = false } -paste = "1.0.25" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment +paste = "1.0.15" # Paste is no longer maintained (RUSTSEC-2024-0436). Fixing the used version to latest one. Not making the move to Pastey at the moment log = "0.4" embassy-sync = "0.7.2" embassy-futures = "0.1.2"