mirror of
https://github.com/Noratrieb/cluelessh.git
synced 2026-01-14 16:35:06 +01:00
bootstrap SFTP subsystem
This commit is contained in:
parent
8de8204bc7
commit
a9e2edc572
15 changed files with 205 additions and 17 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -422,6 +422,17 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cluelessh-sftp"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cluelessh-sftp-proto"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cluelessh-transport",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cluelessh-tokio"
|
name = "cluelessh-tokio"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -489,6 +500,15 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cluelesshd-sftp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"eyre",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,11 @@ async fn handle_session_channel(
|
||||||
channel.send(ChannelOperationKind::Eof).await?;
|
channel.send(ChannelOperationKind::Eof).await?;
|
||||||
channel.send(ChannelOperationKind::Close).await?;
|
channel.send(ChannelOperationKind::Close).await?;
|
||||||
}
|
}
|
||||||
|
ChannelRequest::Subsystem { want_reply, .. } => {
|
||||||
|
if want_reply {
|
||||||
|
channel.send(ChannelOperationKind::Failure).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
ChannelRequest::ExitStatus { .. } => {}
|
ChannelRequest::ExitStatus { .. } => {}
|
||||||
ChannelRequest::Env { .. } => {}
|
ChannelRequest::Env { .. } => {}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
12
bin/cluelesshd-sftp-server/Cargo.toml
Normal file
12
bin/cluelesshd-sftp-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "cluelesshd-sftp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eyre.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
3
bin/cluelesshd-sftp-server/README.md
Normal file
3
bin/cluelesshd-sftp-server/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# cluelesshd-sftp-server
|
||||||
|
|
||||||
|
SFTP server for cluelesshd.
|
||||||
17
bin/cluelesshd-sftp-server/src/main.rs
Normal file
17
bin/cluelesshd-sftp-server/src/main.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
use eyre::Result;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let env_filter =
|
||||||
|
EnvFilter::try_from_env("SFTP_LOG").unwrap_or_else(|_| EnvFilter::new("debug"));
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("mroooow!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
|
|
||||||
|
|
||||||
|
[subsystem.sftp]
|
||||||
|
path = "../../target/debug/cluelesshd-sftp-server"
|
||||||
|
|
||||||
[net]
|
[net]
|
||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
port = 2223
|
port = 2223
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
use eyre::{Context, Result};
|
use eyre::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
net::{IpAddr, Ipv4Addr},
|
net::{IpAddr, Ipv4Addr},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::Args;
|
use crate::Args;
|
||||||
|
|
||||||
|
// TODO: validate config and user nicer structs to consume it
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
@ -15,6 +18,8 @@ pub struct Config {
|
||||||
pub net: NetConfig,
|
pub net: NetConfig,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub security: SecurityConfig,
|
pub security: SecurityConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subsystem: HashMap<String, SubsystemConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -52,8 +57,21 @@ pub struct SecurityConfig {
|
||||||
pub experimental_seccomp: bool,
|
pub experimental_seccomp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add arbitrary subsystems.
|
||||||
|
/// # Subsystem Protocol
|
||||||
|
/// Every subsystem process gets spawned in the home directory of the user, as the user.
|
||||||
|
/// Several FDs are guaranteed to be open.
|
||||||
|
/// - stdin (0): data from the client channel
|
||||||
|
/// - stdout (1): data to the client channel
|
||||||
|
/// - stderr (2): data to the client channel extended stderr (used for debugging)
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubsystemConfig {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn find(args: &Args) -> Result<Self> {
|
pub fn load(args: &Args) -> Result<Self> {
|
||||||
let path = std::env::var("CLUELESSHD_CONFIG")
|
let path = std::env::var("CLUELESSHD_CONFIG")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or(args.config.clone().ok_or(std::env::VarError::NotPresent))
|
.or(args.config.clone().ok_or(std::env::VarError::NotPresent))
|
||||||
|
|
@ -63,8 +81,19 @@ impl Config {
|
||||||
format!("failed to open config file '{}', refusing to start. you can change the config file path with the --config arg or the CLUELESSHD_CONFIG environment variable", path.display())
|
format!("failed to open config file '{}', refusing to start. you can change the config file path with the --config arg or the CLUELESSHD_CONFIG environment variable", path.display())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
toml::from_str(&content)
|
let mut config: Config = toml::from_str(&content)
|
||||||
.wrap_err_with(|| format!("invalid config file '{}'", path.display()))
|
.wrap_err_with(|| format!("invalid config file '{}'", path.display()))?;
|
||||||
|
|
||||||
|
for sub in config.subsystem.values_mut() {
|
||||||
|
sub.path = sub.path.canonicalize().wrap_err_with(|| {
|
||||||
|
format!(
|
||||||
|
"error canonicalizing subsystem path: {}",
|
||||||
|
sub.path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ impl SessionState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChannelRequest::Shell { want_reply } => match self.shell(None).await {
|
ChannelRequest::Shell { want_reply } => match self.shell(None, None).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if want_reply {
|
if want_reply {
|
||||||
self.channel.send(ChannelOperationKind::Success).await?;
|
self.channel.send(ChannelOperationKind::Success).await?;
|
||||||
|
|
@ -309,7 +309,7 @@ impl SessionState {
|
||||||
want_reply,
|
want_reply,
|
||||||
command,
|
command,
|
||||||
} => match String::from_utf8(command) {
|
} => match String::from_utf8(command) {
|
||||||
Ok(command) => match self.shell(Some(&command)).await {
|
Ok(command) => match self.shell(Some(command), None).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if want_reply {
|
if want_reply {
|
||||||
self.channel.send(ChannelOperationKind::Success).await?;
|
self.channel.send(ChannelOperationKind::Success).await?;
|
||||||
|
|
@ -330,6 +330,21 @@ impl SessionState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
ChannelRequest::Subsystem { want_reply, name } => {
|
||||||
|
match self.shell(None, Some(name)).await {
|
||||||
|
Ok(()) => {
|
||||||
|
if want_reply {
|
||||||
|
self.channel.send(ChannelOperationKind::Success).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(%err, "Failed to spawn subsystem");
|
||||||
|
if want_reply {
|
||||||
|
self.channel.send(ChannelOperationKind::Failure).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ChannelRequest::Env {
|
ChannelRequest::Env {
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
|
|
@ -394,11 +409,12 @@ impl SessionState {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shell(&mut self, shell_command: Option<&str>) -> Result<()> {
|
async fn shell(&mut self, shell_command: Option<String>, subsystem: Option<String>) -> Result<()> {
|
||||||
let mut fds = self
|
let mut fds = self
|
||||||
.rpc_client
|
.rpc_client
|
||||||
.shell(
|
.shell(
|
||||||
shell_command.map(ToOwned::to_owned),
|
shell_command,
|
||||||
|
subsystem,
|
||||||
self.pty_term.clone(),
|
self.pty_term.clone(),
|
||||||
self.envs.clone(),
|
self.envs.clone(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use eyre::{bail, eyre, Context, Result};
|
||||||
use rustix::fs::MemfdFlags;
|
use rustix::fs::MemfdFlags;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{ error, info, warn};
|
||||||
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
|
@ -51,10 +51,14 @@ fn main() -> eyre::Result<()> {
|
||||||
// Initial setup
|
// Initial setup
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let config = config::Config::find(&args)?;
|
let config = config::Config::load(&args)?;
|
||||||
|
|
||||||
setup_tracing(&config);
|
setup_tracing(&config);
|
||||||
|
|
||||||
|
for (name, system) in &config.subsystem {
|
||||||
|
info!(%name, path = %system.path.display(), "Loaded subsystem");
|
||||||
|
}
|
||||||
|
|
||||||
if !rustix::process::getuid().is_root() {
|
if !rustix::process::getuid().is_root() {
|
||||||
warn!("Daemon not started as root. This disables several security mitigations and permits logging in as any other user");
|
warn!("Daemon not started as root. This disables several security mitigations and permits logging in as any other user");
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +201,8 @@ async fn spawn_connection_child(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let stream_fd = stream.as_raw_fd();
|
let stream_fd = stream.as_raw_fd();
|
||||||
|
|
||||||
let mut rpc_server = rpc::Server::new(host_keys).wrap_err("creating RPC server")?;
|
let mut rpc_server =
|
||||||
|
rpc::Server::new(config.clone(), host_keys).wrap_err("creating RPC server")?;
|
||||||
|
|
||||||
let rpc_client_fd = rpc_server.client_fd().as_raw_fd();
|
let rpc_client_fd = rpc_server.client_fd().as_raw_fd();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ use users::os::unix::UserExt;
|
||||||
use users::User;
|
use users::User;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
enum Request {
|
enum Request {
|
||||||
/// Performs the key exchange by generating a private key, deriving the shared secret,
|
/// Performs the key exchange by generating a private key, deriving the shared secret,
|
||||||
|
|
@ -136,6 +138,7 @@ struct ShellRequest {
|
||||||
/// Whether a PTY is used and if yes, the TERM env var.
|
/// Whether a PTY is used and if yes, the TERM env var.
|
||||||
pty_term: Option<String>,
|
pty_term: Option<String>,
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
|
subsystem: Option<String>,
|
||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,17 +165,20 @@ pub struct Server {
|
||||||
host_keys: Vec<PlaintextPrivateKey>,
|
host_keys: Vec<PlaintextPrivateKey>,
|
||||||
authenticated_user: Option<users::User>,
|
authenticated_user: Option<users::User>,
|
||||||
|
|
||||||
|
config: Config,
|
||||||
|
|
||||||
pty_user: Option<OwnedFd>,
|
pty_user: Option<OwnedFd>,
|
||||||
shell_process: Option<Child>,
|
shell_process: Option<Child>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
pub fn new(host_keys: Vec<PlaintextPrivateKey>) -> Result<Self> {
|
pub fn new(config: Config, host_keys: Vec<PlaintextPrivateKey>) -> Result<Self> {
|
||||||
let (server, client) = UnixDatagram::pair().wrap_err("creating socketpair")?;
|
let (server, client) = UnixDatagram::pair().wrap_err("creating socketpair")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
server,
|
server,
|
||||||
client,
|
client,
|
||||||
|
config,
|
||||||
host_keys,
|
host_keys,
|
||||||
authenticated_user: None,
|
authenticated_user: None,
|
||||||
pty_user: None,
|
pty_user: None,
|
||||||
|
|
@ -366,13 +372,28 @@ impl Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shell(&mut self, user: &User, req: ShellRequest) -> Result<Vec<OwnedFd>> {
|
async fn shell(&mut self, user: &User, req: ShellRequest) -> Result<Vec<OwnedFd>> {
|
||||||
|
let subsystem = match req.subsystem.as_deref() {
|
||||||
|
Some(subsystem) => match self.config.subsystem.get(subsystem) {
|
||||||
|
Some(system) => Some(system.path.clone()),
|
||||||
|
None => bail!("unsupported subsystem: {subsystem}"),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let shell = user.shell();
|
let shell = user.shell();
|
||||||
|
|
||||||
let mut cmd = Command::new(shell);
|
let cmd_arg0 = subsystem.as_deref().unwrap_or(shell);
|
||||||
if let Some(shell_command) = req.command {
|
|
||||||
cmd.arg("-c");
|
// TODO: the SSH RFC mentions subsystems going through shell... should we?
|
||||||
cmd.arg(shell_command);
|
let mut cmd = Command::new(cmd_arg0);
|
||||||
}
|
|
||||||
|
if subsystem.is_none() {
|
||||||
|
if let Some(shell_command) = req.command {
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg(shell_command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
cmd.env_clear();
|
cmd.env_clear();
|
||||||
|
|
||||||
let has_pty = req.pty_term.is_some();
|
let has_pty = req.pty_term.is_some();
|
||||||
|
|
@ -404,7 +425,7 @@ impl Server {
|
||||||
cmd.env(k, v);
|
cmd.env(k, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(cmd = %shell.display(), uid = %user.uid(), gid = %user.primary_group_id(), "Executing process");
|
debug!(cmd = %cmd_arg0.display(), uid = %user.uid(), gid = %user.primary_group_id(), "Executing process");
|
||||||
|
|
||||||
let mut shell = cmd.spawn()?;
|
let mut shell = cmd.spawn()?;
|
||||||
|
|
||||||
|
|
@ -533,12 +554,14 @@ impl Client {
|
||||||
pub async fn shell(
|
pub async fn shell(
|
||||||
&self,
|
&self,
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
|
subsystem: Option<String>,
|
||||||
pty_term: Option<String>,
|
pty_term: Option<String>,
|
||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
) -> Result<Vec<OwnedFd>> {
|
) -> Result<Vec<OwnedFd>> {
|
||||||
self.send_request(&Request::Shell(ShellRequest {
|
self.send_request(&Request::Shell(ShellRequest {
|
||||||
pty_term,
|
pty_term,
|
||||||
command,
|
command,
|
||||||
|
subsystem,
|
||||||
env,
|
env,
|
||||||
}))
|
}))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,11 @@ pub enum ChannelRequest {
|
||||||
|
|
||||||
command: Vec<u8>,
|
command: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
Subsystem {
|
||||||
|
want_reply: bool,
|
||||||
|
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
Env {
|
Env {
|
||||||
want_reply: bool,
|
want_reply: bool,
|
||||||
|
|
||||||
|
|
@ -471,6 +476,19 @@ impl ChannelsState {
|
||||||
command: command.to_owned(),
|
command: command.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"subsystem" => {
|
||||||
|
if !self.is_server {
|
||||||
|
return Err(peer_error!("server tried to set environment var"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = p.utf8_string()?;
|
||||||
|
|
||||||
|
info!(channel = %our_channel, %name, "Starting subsystem");
|
||||||
|
ChannelRequest::Subsystem {
|
||||||
|
want_reply,
|
||||||
|
name: name.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
"env" => {
|
"env" => {
|
||||||
if !self.is_server {
|
if !self.is_server {
|
||||||
return Err(peer_error!("server tried to set environment var"));
|
return Err(peer_error!("server tried to set environment var"));
|
||||||
|
|
@ -632,6 +650,7 @@ impl ChannelsState {
|
||||||
Packet::new_msg_channel_request_shell(peer, b"shell", want_reply)
|
Packet::new_msg_channel_request_shell(peer, b"shell", want_reply)
|
||||||
}
|
}
|
||||||
ChannelRequest::Exec { .. } => todo!("exec"),
|
ChannelRequest::Exec { .. } => todo!("exec"),
|
||||||
|
ChannelRequest::Subsystem { .. } => todo!("subsystem"),
|
||||||
ChannelRequest::Env { .. } => todo!("env"),
|
ChannelRequest::Env { .. } => todo!("env"),
|
||||||
ChannelRequest::ExitStatus { status } => {
|
ChannelRequest::ExitStatus { status } => {
|
||||||
Packet::new_msg_channel_request_exit_status(
|
Packet::new_msg_channel_request_exit_status(
|
||||||
|
|
@ -787,6 +806,7 @@ impl ChannelOperation {
|
||||||
ChannelRequest::PtyReq { .. } => "pty-req",
|
ChannelRequest::PtyReq { .. } => "pty-req",
|
||||||
ChannelRequest::Shell { .. } => "shell",
|
ChannelRequest::Shell { .. } => "shell",
|
||||||
ChannelRequest::Exec { .. } => "exec",
|
ChannelRequest::Exec { .. } => "exec",
|
||||||
|
ChannelRequest::Subsystem { .. } => "subsystem",
|
||||||
ChannelRequest::Env { .. } => "env",
|
ChannelRequest::Env { .. } => "env",
|
||||||
ChannelRequest::ExitStatus { .. } => "exit-status",
|
ChannelRequest::ExitStatus { .. } => "exit-status",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
10
lib/cluelessh-sftp-proto/Cargo.toml
Normal file
10
lib/cluelessh-sftp-proto/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "cluelessh-sftp-proto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cluelessh-transport = { path = "../cluelessh-transport" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
1
lib/cluelessh-sftp-proto/src/lib.rs
Normal file
1
lib/cluelessh-sftp-proto/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
9
lib/cluelessh-sftp/Cargo.toml
Normal file
9
lib/cluelessh-sftp/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "cluelessh-sftp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
14
lib/cluelessh-sftp/src/lib.rs
Normal file
14
lib/cluelessh-sftp/src/lib.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
pub fn add(left: u64, right: u64) -> u64 {
|
||||||
|
left + right
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
let result = add(2, 2);
|
||||||
|
assert_eq!(result, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue