use std::time::Duration;
use rmcp::{ErrorData as McpError, model::*, schemars};
use serde::Deserialize;
use crate::audit::AuditRecord;
use crate::config::AuthMethod;
use crate::errors::SshError;
use crate::session::exec;
pub mod discovery;
pub mod files;
pub mod net;
pub mod ops;
pub mod run;
pub mod session;
pub mod visual;
pub(crate) const DEFAULT_TIMEOUT: u64 = 60;
pub(crate) const MAX_TIMEOUT_SECS: u64 = 600;
pub(crate) const MAX_FOLLOW_SECS: u64 = 600;
pub(crate) const INLINE_MAX_BYTES: usize = 256 * 1024;
pub(crate) const MAX_CMD_BYTES: usize = 64 * 1024;
pub(crate) const MAX_BATCH_CMDS: usize = 64;
pub(crate) const MAX_WRITE_INLINE_BYTES: usize = 8 * 1024 * 1024;
pub(crate) const MAX_LS_ENTRIES: usize = 1000;
pub(crate) const MAX_NAMED_PTYS: usize = 8;
pub(crate) fn clamp_timeout(t: Option<u64>) -> Duration {
Duration::from_secs(t.unwrap_or(DEFAULT_TIMEOUT).clamp(1, MAX_TIMEOUT_SECS))
}
pub(crate) fn validate_cmd(cmd: &str) -> Result<(), McpError> {
if cmd.len() > MAX_CMD_BYTES {
return Err(SshError::Config(format!(
"cmd too large: {} bytes (max {})",
cmd.len(),
MAX_CMD_BYTES
))
.into_mcp());
}
Ok(())
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct HostOnlyArgs {
#[serde(default)]
pub host: Option<String>,
}
pub(crate) fn auth_str(a: AuthMethod) -> &'static str {
match a {
AuthMethod::Key => "key",
AuthMethod::Agent => "agent",
AuthMethod::Password => "password",
}
}
pub(crate) fn text(s: String) -> CallToolResult {
CallToolResult::success(vec![ContentBlock::text(s)])
}
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
impl crate::server::SshServer {
pub(crate) async fn guard_resolved(
&self,
host: &str,
tool: &'static str,
session: &crate::session::Session,
path: &str,
write: bool,
) -> Result<(), McpError> {
let resolved = match crate::sftp::resolve_path(session, path).await {
Ok(p) => p,
Err(e) => {
let msg = format!("cannot verify what {path} resolves to: {e}");
self.audit
.write(host, tool, AuditRecord::blocked(path, &msg));
return Err(SshError::BlockedByGuard {
name: "unresolvable-path".into(),
pattern: msg,
}
.into_mcp());
}
};
if resolved == path {
return Ok(());
}
let guards = self.guards().for_host(host);
let verdict = if write {
guards.check_sftp_write(&resolved)
} else {
guards.check_sftp_read(&resolved)
};
if let Err(e) = verdict {
let msg = format!("{path} resolves to {resolved}: {e}");
self.audit
.write(host, tool, AuditRecord::blocked(path, &msg));
return Err(SshError::BlockedByGuard {
name: "resolved-path".into(),
pattern: msg,
}
.into_mcp());
}
Ok(())
}
}
pub(crate) fn batch_preview(r: &exec::ExecResult, verbose: bool) -> String {
let (src, max) = if r.exit_code != 0 {
let stderr_trimmed = r.stderr.trim();
let s: &str = if !stderr_trimmed.is_empty() {
stderr_trimmed
} else {
r.stdout.as_str()
};
(s, 200)
} else if verbose {
(r.stdout.as_str(), 200)
} else {
(r.stdout.as_str(), 40)
};
let mut end = max.min(src.len());
while end < src.len() && !src.is_char_boundary(end) {
end -= 1;
}
src[..end].replace('\n', " ")
}
#[cfg(test)]
mod tests {
use super::shell_quote;
#[test]
fn quoting_neutralises_metacharacters() {
assert_eq!(shell_quote("/tmp/a b"), "'/tmp/a b'");
assert_eq!(shell_quote("/tmp/$(id)"), "'/tmp/$(id)'");
assert_eq!(shell_quote("/tmp/x;rm -rf /"), "'/tmp/x;rm -rf /'");
assert_eq!(shell_quote("a b; c"), "'a b; c'");
}
#[test]
fn a_single_quote_is_spliced_not_escaped() {
assert_eq!(shell_quote("/tmp/it's"), r"'/tmp/it'\''s'");
}
#[test]
fn plain_values_are_still_quoted() {
assert_eq!(shell_quote("plain"), "'plain'");
assert_eq!(shell_quote("/var/log/syslog"), "'/var/log/syslog'");
}
}