02 — Architecture¶
2.1 Layering¶
graph TD
A["Layer 4 — Convenience<br/>Exec, Shell, sftp::copy_file, forwarding pumps, RemoteProcess"]
B["Layer 3 — Domain objects<br/>Session, Channel, sftp::Sftp, scp::Reader/Writer, server::Bind"]
C["Layer 2 — Error & policy<br/>error_code, Error, Result<T>, HostKeyVerifier, Authenticator"]
D["Layer 1 — RAII handles<br/>detail::UniqueHandle<T, Deleter>, detail::SessionCore"]
E["Layer 0 — libssh C API<br/>libssh.h, sftp.h, server.h, callbacks.h"]
A --> B --> C --> D --> E
Rules:
- A layer may only depend on layers below it.
- Layer 4 is strictly optional sugar: everything it does can be done with Layer 3. It lives in separate headers so users who do not want it pay nothing.
- Layer 0 symbols never appear in public headers (see §2.4).
2.2 Ownership and lifetime model¶
libssh has a hard destruction-ordering requirement: ssh_free(session) invalidates every
channel and SFTP session derived from it, and ssh_channel_free must happen before
ssh_free. A naive wrapper where Channel stores a raw ssh_channel and Session stores a
raw ssh_session lets users write dangling code that compiles fine.
Decision: shared session core¶
namespace sshpp::detail {
// Non-copyable, non-movable; always heap-allocated and shared.
class SessionCore {
public:
explicit SessionCore(ssh_session raw) noexcept;
~SessionCore(); // ssh_disconnect + ssh_free, last one out
ssh_session raw() const noexcept { return raw_; }
// libssh sessions are NOT thread-safe: one session = one logical lock.
std::recursive_mutex& mutex() noexcept { return mutex_; }
bool valid() const noexcept { return raw_ != nullptr; }
void invalidate() noexcept; // used by Session::release()
private:
ssh_session raw_;
std::recursive_mutex mutex_;
std::atomic<bool> cancel_requested_{false};
LogSink log_sink_; // per-session log callback (0.11) or global fallback
};
using SessionCorePtr = std::shared_ptr<SessionCore>;
} // namespace sshpp::detail
Sessionholds aSessionCorePtr(strong).Channel,sftp::Sftp,scp::Reader/Writer,RemoteForwardListenereach hold their ownSessionCorePtr(strong).sftp::File/sftp::Dirhold a strong ref to thesftp::Sftpinternals, which in turn holds theSessionCorePtr.
Consequence: a Channel keeps the session alive. Session going out of scope while a
Channel lives does not free the ssh_session; the last owner frees it. This trades a
"surprising" extended lifetime for guaranteed memory safety, which is the right trade for a
security-adjacent library.
Session::disconnect() is separate from destruction: it sends the disconnect message and marks
the core disconnected, but memory is still freed only by the last owner. Operations on a
Channel after Session::disconnect() fail with error_code::not_connected rather than
crashing.
graph LR
S[Session] -- shared_ptr --> C[SessionCore]
CH[Channel] -- shared_ptr --> C
SF["sftp::Sftp"] -- shared_ptr --> C
F["sftp::File"] -- shared_ptr --> SFI["sftp::detail::SftpCore"]
SFI -- shared_ptr --> C
C -- owns --> R["ssh_session (C handle)"]
Handle wrapper¶
Every other libssh handle that has a clean single-owner lifetime uses a zero-overhead unique wrapper:
namespace sshpp::detail {
template <class Handle, class Deleter>
class UniqueHandle {
public:
UniqueHandle() = default;
explicit UniqueHandle(Handle h) noexcept : h_(h) {}
UniqueHandle(UniqueHandle&& o) noexcept : h_(std::exchange(o.h_, nullptr)) {}
UniqueHandle& operator=(UniqueHandle&& o) noexcept;
~UniqueHandle() { if (h_) Deleter{}(h_); }
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
Handle get() const noexcept { return h_; }
explicit operator bool() const noexcept { return h_ != nullptr; }
[[nodiscard]] Handle release() noexcept { return std::exchange(h_, nullptr); }
void reset(Handle h = nullptr) noexcept;
private:
Handle h_ = nullptr;
};
} // namespace sshpp::detail
Used for ssh_key, ssh_message, ssh_string, ssh_buffer, ssh_event, ssh_bind,
sftp_attributes, sftp_statvfs_t, ssh_pcap_file.
Ownership policy for adoption¶
Every domain type provides:
enum class Ownership { owning, borrowed };
static Channel from_native(ssh_channel raw, const Session& parent, Ownership o);
ssh_channel native_handle() const noexcept;
[[nodiscard]] ssh_channel release() noexcept; // give up ownership, wrapper becomes empty
Ownership::borrowed is required for the server callback API, where libssh hands you channels
it still owns.
2.3 Move-only, never copyable¶
All handle-owning types are move-only. Copying an SSH session or channel is meaningless.
SessionOptions, sftp::Attributes, Fingerprint, KnownHostsEntry and other value types
are copyable.
Moved-from objects are in a valid, empty state: explicit operator bool() returns false, and
any operation returns/throws error_code::invalid_handle.
2.4 Hiding the C API¶
Public headers must not include <libssh/libssh.h> (NFR-4). Reasons: libssh's headers pull in
<winsock2.h> / <sys/socket.h>, define macros like SSH_OK/SSH_ERROR that collide, and
would force every consumer onto libssh's include path even in header-only distributions of
downstream code.
Technique — forward-declare the opaque struct pointers in a private header:
// include/sshpp/detail/native_fwd.hpp
namespace sshpp {
// libssh's public typedefs are `struct ssh_session_struct*` etc.
using native_session = struct ssh_session_struct*;
using native_channel = struct ssh_channel_struct*;
using native_key = struct ssh_key_struct*;
using native_bind = struct ssh_bind_struct*;
using native_message = struct ssh_message_struct*;
using native_event = struct ssh_event_struct*;
using native_sftp = struct sftp_session_struct*;
using native_sftp_file= struct sftp_file_struct*;
using native_sftp_dir = struct sftp_dir_struct*;
using native_scp = struct ssh_scp_struct*;
} // namespace sshpp
These are exactly the types libssh typedefs, so native_session and ssh_session are the
same type and interconvert without a cast. A static assertion in the .cpp files
(static_assert(std::is_same_v<native_session, ssh_session>)) guards against libssh changing
its definitions.
Enums that appear in the public API (key types, log levels, SFTP error codes, auth methods) are
re-declared as enum class in sshpp with explicit values, and a constexpr translation
table in the .cpp maps them, with static_asserts pinning each value to the libssh macro.
This is verified by tests/unit/enum_mapping_test.cpp.
2.5 Threading model¶
| Object | Guarantee |
|---|---|
sshpp::Library |
Thread-safe; idempotent; must be alive before any other object |
Session and everything derived from it (Channel, sftp::*, scp::*) |
Not thread-safe as a group. All of them share one SessionCore mutex. |
Distinct Sessions |
Fully independent; safe to use concurrently from different threads |
Value types (SessionOptions, Attributes, Key) |
As-if const — safe to read concurrently |
Two modes, chosen at construction via SessionOptions::locking:
Locking::none(default) — no internal locking; the caller must not touch the session tree from two threads. Zero overhead. Debug builds insert a thread-affinity assertion.Locking::internal— every public call takesSessionCore::mutex(). Makes the session tree safe to share, at the cost of coarse serialization. Required if you want toread()on a channel in one thread whilewrite()ing in another (libssh does not support true parallel I/O on one session, so calls interleave rather than overlap).
Library installs libssh's native threading callbacks (ssh_threads_get_pthread() /
ssh_threads_get_native()) once, which is what makes multiple concurrent sessions safe.
Cancellation. Session::request_cancel() is the only method callable from another thread
in Locking::none mode. It sets an atomic flag and, if the session is in a blocking libssh
call, breaks it via the registered poll-based interrupt (self-pipe added to the session's
ssh_event). Subsequent calls fail with error_code::cancelled until
Session::clear_cancel().
2.6 Blocking, timeouts and SSH_AGAIN¶
v1 is synchronous. Sessions are created in blocking mode. Timeouts map as follows:
| API shape | libssh mechanism |
|---|---|
SessionOptions::timeout |
SSH_OPTIONS_TIMEOUT + SSH_OPTIONS_TIMEOUT_USEC — applies to connect and all blocking reads |
Channel::read_some(buf, timeout) |
ssh_channel_read_timeout |
Channel::wait_readable(timeout) |
ssh_channel_poll_timeout |
Event::poll(timeout) |
ssh_event_dopoll |
A timeout expiry is not an exception in try_* form; it returns
error_code::timed_out. The throwing form throws TimeoutError. SSH_AGAIN is only visible
to users who explicitly opt into non-blocking mode via Session::set_blocking(false), in which
case it maps to error_code::would_block.
2.7 Buffers and string types¶
- Input byte ranges:
sshpp::ByteView=span<const std::byte>shim (aliasesstd::spanunder C++20). Convenience overloads acceptstd::string_viewandconst std::vector<std::byte>&. - Output byte ranges:
sshpp::MutableByteView. - Text (hostnames, usernames, commands, banners):
std::string_viewin,std::stringout. - Remote paths: a dedicated
sshpp::RemotePath(thinstd::stringwrapper, always/separated) — notstd::filesystem::path, because on Windowspathwould mangle separators.std::filesystem::pathis used only for local paths. - Secrets (passwords, passphrases):
sshpp::SecureString— astd::basic_stringwith an allocator thatexplicit_bzero/SecureZeroMemorys on deallocation, plus deletedoperator<<. Password-taking APIs acceptSecureStringorstd::string_view(the latter documented as caller's responsibility).
2.8 Directory layout¶
libsshpp/
├── CMakeLists.txt
├── CMakePresets.json
├── conanfile.py # recipe (also usable as consumer via `conan build`)
├── LICENSE # LGPL-2.1
├── THIRD_PARTY_NOTICES.md
├── README.md
├── CHANGELOG.md
├── cmake/
│ ├── libsshppConfig.cmake.in
│ ├── CompilerWarnings.cmake
│ ├── Sanitizers.cmake
│ ├── FindLibssh.cmake # fallback when Conan/CONFIG mode unavailable
│ └── libsshpp-header-only.cmake
├── include/sshpp/
│ ├── sshpp.hpp # umbrella header
│ ├── config.hpp # generated: version + feature macros
│ ├── export.hpp # generated: SSHPP_API visibility macros
│ ├── fwd.hpp # forward declarations of every public type
│ ├── library.hpp # Library, logging, feature query
│ ├── error.hpp # error_code, error_category, Error hierarchy
│ ├── result.hpp # Result<T>, Result<void>
│ ├── types.hpp # ByteView, RemotePath, SecureString, enums
│ ├── session.hpp
│ ├── session_options.hpp
│ ├── auth.hpp # Authenticator interface + built-ins
│ ├── key.hpp
│ ├── known_hosts.hpp
│ ├── host_key_verifier.hpp
│ ├── channel.hpp
│ ├── channel_stream.hpp # streambuf/istream/ostream adapters
│ ├── exec.hpp # Layer-4 Exec/RemoteProcess
│ ├── shell.hpp # Layer-4 interactive shell + PTY
│ ├── event.hpp
│ ├── forwarding.hpp
│ ├── pcap.hpp # optional
│ ├── sftp/
│ │ ├── sftp.hpp
│ │ ├── file.hpp
│ │ ├── directory.hpp
│ │ ├── attributes.hpp
│ │ └── algorithms.hpp # Layer-4: copy_file, copy_tree, sync
│ ├── scp/
│ │ └── scp.hpp
│ ├── server/
│ │ ├── bind.hpp
│ │ ├── server_session.hpp
│ │ ├── message.hpp
│ │ ├── handlers.hpp # callback-style interfaces
│ │ └── test_server.hpp # Layer-4 in-process server for tests
│ └── detail/
│ ├── native_fwd.hpp
│ ├── unique_handle.hpp
│ ├── session_core.hpp
│ ├── enum_map.hpp
│ └── *.ipp # inline definitions for header-only mode
├── src/ # one .cpp per public header (skipped in header-only mode)
│ ├── library.cpp
│ ├── error.cpp
│ ├── session.cpp
│ └── …
├── tests/
│ ├── unit/ # no network; enum maps, options, Result, RemotePath, …
│ ├── integration/ # against in-process server::TestServer
│ ├── system/ # against Dockerized OpenSSH (opt-in)
│ ├── fuzz/ # libFuzzer targets for parsers we own
│ └── CMakeLists.txt
├── examples/
│ ├── 01_exec.cpp
│ ├── 02_interactive_shell.cpp
│ ├── 03_sftp_download.cpp
│ ├── 04_sftp_directory_walk.cpp
│ ├── 05_scp_upload.cpp
│ ├── 06_local_port_forward.cpp
│ ├── 07_remote_port_forward.cpp
│ ├── 08_known_hosts_tofu.cpp
│ ├── 09_keygen.cpp
│ └── 10_minimal_server.cpp
├── bench/ # google-benchmark: throughput, wrapper overhead
├── docs/
│ ├── design/ # this directory
│ ├── Doxyfile.in
│ └── usage/ # tutorials generated into the site
└── test_package/ # Conan 2 test_package
├── conanfile.py
├── CMakeLists.txt
└── example.cpp
2.9 Naming conventions¶
| Kind | Convention | Example |
|---|---|---|
| Namespaces | snake_case, short |
sshpp, sshpp::sftp, sshpp::server |
| Types | PascalCase |
Session, RemotePath, HostKeyVerifier |
| Functions / methods | snake_case |
read_some, request_pty, is_known_server |
| Non-throwing sibling | try_ prefix, returns Result<T> |
try_connect, try_read_some |
| Enums | enum class, PascalCase type, snake_case enumerators |
KeyType::ed25519 |
| Member fields | trailing underscore | raw_, core_ |
| Macros | SSHPP_ prefix, SCREAMING_CASE |
SSHPP_API, SSHPP_HAS_SFTP_AIO |
| Files | snake_case.hpp / .cpp / .ipp |
session_options.hpp |
[[nodiscard]] is applied to every Result-returning function, every getter, and release().
Every type that can be empty has explicit operator bool().
2.10 Key architectural decisions (ADR summary)¶
| ID | Decision | Alternatives rejected | Rationale |
|---|---|---|---|
| ADR-1 | Shared SessionCore for lifetime safety |
Raw back-pointer; weak_ptr with throw-on-expired |
Raw pointer = UB on misuse. weak_ptr still requires the user to keep the Session alive, which is the exact footgun we're removing. |
| ADR-2 | Opaque native typedefs; no libssh headers in public API | Include <libssh/libssh.h> |
Macro pollution (SSH_OK, SSH_ERROR), winsock2.h ordering hazards, forces include path onto consumers. |
| ADR-3 | Dual API: throwing + try_ returning Result<T> |
std::error_code& out-params; exceptions only |
try_ is greppable, composable, and doesn't create overload ambiguity with defaulted args (which ec& out-params do). |
| ADR-4 | enum class re-declaration with static_assert mapping |
Pass through libssh macros | Type safety + ADR-2. Cost is one translation table per enum, verified by tests. |
| ADR-5 | Synchronous-only v1 | Ship coroutines in v1 | libssh's non-blocking mode is SSH_AGAIN-based and subtly stateful; getting it right deserves its own release. Event provides the integration seam. |
| ADR-6 | Opt-in internal locking rather than always-locking | Always lock; never lock | Most users are single-threaded per session and shouldn't pay; those who need it get correctness with one option. |
| ADR-7 | RemotePath distinct from std::filesystem::path |
Use std::filesystem::path everywhere |
On Windows path normalizes to \, silently corrupting remote paths. |
| ADR-8 | Server API ships in v1 | Client-only v1 | It is what makes the test suite hermetic (G9), and is a differentiator vs. other wrappers. |
| ADR-9 | Compiled library by default, header-only opt-in | Header-only only | Compile times and ODR/visibility control; header-only remains available for single-binary/vendored use. |