#![forbid(unsafe_code)]
use crate::constants::SFTP_MAX_RECURSION_DEPTH;
use crate::errors::{SshCliError, SshCliResult};
pub fn validate_remote_path(path: &str) -> SshCliResult<()> {
if path.is_empty() {
return Err(SshCliError::InvalidArgument(
"sftp remote path must not be empty".into(),
));
}
if path.chars().any(|c| c.is_control() || c == '\0') {
return Err(SshCliError::InvalidArgument(
"sftp remote path must not contain control characters".into(),
));
}
let stripped = path.trim_start_matches('/');
if stripped.is_empty() {
return Ok(());
}
for seg in stripped.split('/') {
if seg.is_empty() {
return Err(SshCliError::InvalidArgument(format!(
"sftp remote path has empty segment: {path}"
)));
}
}
Ok(())
}
pub fn validate_entry_name(name: &str) -> SshCliResult<()> {
if name.is_empty() {
return Err(SshCliError::InvalidArgument(
"sftp entry name must not be empty".into(),
));
}
if name == "." || name == ".." {
return Err(SshCliError::InvalidArgument(format!(
"sftp entry name refuses relative segment: {name}"
)));
}
if name.chars().any(|c| c.is_control() || c == '\0' || c == '/' || c == '\\') {
return Err(SshCliError::InvalidArgument(format!(
"sftp entry name must not contain controls or path separators: {name}"
)));
}
Ok(())
}
#[must_use]
pub fn join_remote(parent: &str, name: &str) -> String {
if parent.is_empty() || parent == "." {
return name.to_owned();
}
if parent.ends_with('/') {
format!("{parent}{name}")
} else {
format!("{parent}/{name}")
}
}
pub fn check_depth(depth: u32) -> SshCliResult<()> {
if depth > SFTP_MAX_RECURSION_DEPTH {
return Err(SshCliError::InvalidArgument(format!(
"sftp recursion depth {depth} exceeds max {SFTP_MAX_RECURSION_DEPTH}"
)));
}
Ok(())
}
#[must_use]
#[allow(dead_code)]
pub fn remote_is_under(root: &str, candidate: &str) -> bool {
if root.is_empty() {
return false;
}
if candidate == root {
return true;
}
let root_slash = if root.ends_with('/') {
root.to_owned()
} else {
format!("{root}/")
};
candidate.starts_with(&root_slash)
}
pub fn ensure_local_under(root: &std::path::Path, candidate: &std::path::Path) -> SshCliResult<()> {
use std::path::Component;
if candidate
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(SshCliError::InvalidArgument(format!(
"sftp local path must not contain '..': {}",
candidate.display()
)));
}
let root_c = root.components().collect::<Vec<_>>();
let cand_c = candidate.components().collect::<Vec<_>>();
if cand_c.len() < root_c.len() {
return Err(SshCliError::InvalidArgument(format!(
"sftp local path escapes destination root: {}",
candidate.display()
)));
}
if cand_c[..root_c.len()] != root_c[..] {
return Err(SshCliError::InvalidArgument(format!(
"sftp local path escapes destination root: {}",
candidate.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn validate_accepts_normal_paths() {
validate_remote_path("/var/log/app.log").unwrap();
validate_remote_path("relative/file").unwrap();
validate_remote_path("/").unwrap();
validate_remote_path(".").unwrap();
}
#[test]
fn validate_rejects_empty_and_controls() {
assert!(validate_remote_path("").is_err());
assert!(validate_remote_path("a\0b").is_err());
assert!(validate_remote_path("a\nb").is_err());
assert!(validate_remote_path("/a//b").is_err());
}
#[test]
fn entry_name_rejects_escape() {
validate_entry_name("ok-file.txt").unwrap();
assert!(validate_entry_name("").is_err());
assert!(validate_entry_name(".").is_err());
assert!(validate_entry_name("..").is_err());
assert!(validate_entry_name("a/b").is_err());
assert!(validate_entry_name("a\\b").is_err());
assert!(validate_entry_name("a\0b").is_err());
}
#[test]
fn join_remote_handles_slash() {
assert_eq!(join_remote("/tmp", "x"), "/tmp/x");
assert_eq!(join_remote("/tmp/", "x"), "/tmp/x");
assert_eq!(join_remote(".", "x"), "x");
}
#[test]
fn depth_cap() {
check_depth(0).unwrap();
check_depth(SFTP_MAX_RECURSION_DEPTH).unwrap();
assert!(check_depth(SFTP_MAX_RECURSION_DEPTH + 1).is_err());
}
#[test]
fn under_prefix() {
assert!(remote_is_under("/home/u", "/home/u/a"));
assert!(remote_is_under("/home/u", "/home/u"));
assert!(!remote_is_under("/home/u", "/home/other"));
assert!(!remote_is_under("/home/u", "/home/u2/x"));
}
#[test]
fn local_under() {
let root = Path::new("/tmp/dest");
ensure_local_under(root, Path::new("/tmp/dest/a")).unwrap();
assert!(ensure_local_under(root, Path::new("/tmp/other")).is_err());
assert!(ensure_local_under(root, Path::new("/tmp/dest/../other")).is_err());
}
}