use std::path::{Path, PathBuf};
use bssh::server::scp::{ScpHandler, ScpMode};
use bssh::server::sftp::SftpHandler;
use bssh::shared::auth_types::UserInfo;
use tempfile::tempdir;
fn user() -> UserInfo {
UserInfo::new("work")
}
#[test]
fn scp_no_chroot_accepts_absolute_client_path() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("/home/work/file.bin"),
user(),
None, PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("/home/work/file.bin"))
.expect("absolute path inside home dir should resolve");
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn scp_no_chroot_accepts_path_outside_home() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("/tmp/foo.bin"),
user(),
None,
PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("/tmp/foo.bin"))
.expect("absolute path should resolve");
assert_eq!(resolved, PathBuf::from("/tmp/foo.bin"));
}
#[test]
fn scp_no_chroot_relative_path_lands_in_home() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("file.bin"),
user(),
None,
PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("file.bin"))
.expect("relative path should resolve");
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn scp_chroot_absolute_path_reanchored_under_root() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("/file.bin"),
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("/file.bin"))
.expect("absolute chroot-relative path should resolve");
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn scp_chroot_absolute_host_path_confined_under_root() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("/etc/passwd"),
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("/etc/passwd"))
.expect("absolute host-looking path should be confined, not rejected");
assert_eq!(resolved, PathBuf::from("/home/work/etc/passwd"));
}
#[test]
fn scp_chroot_relative_traversal_clamped() {
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("../etc/passwd"),
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler
.resolve_path(Path::new("../etc/passwd"))
.expect("relative traversal should be clamped, not rejected");
assert!(resolved.starts_with("/home/work"));
assert_eq!(resolved, PathBuf::from("/home/work/etc/passwd"));
}
#[test]
fn sftp_no_chroot_accepts_absolute_client_path() {
let handler = SftpHandler::new(user(), None, PathBuf::from("/home/work"));
let resolved = handler
.resolve_path("/home/work/file.bin")
.expect("absolute path inside home dir should resolve");
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn sftp_no_chroot_accepts_path_outside_home() {
let handler = SftpHandler::new(user(), None, PathBuf::from("/home/work"));
let resolved = handler
.resolve_path("/tmp/foo.bin")
.expect("absolute path should resolve");
assert_eq!(resolved, PathBuf::from("/tmp/foo.bin"));
}
#[test]
fn sftp_no_chroot_relative_path_lands_in_home() {
let handler = SftpHandler::new(user(), None, PathBuf::from("/home/work"));
let resolved = handler.resolve_path("file.bin").unwrap();
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn sftp_chroot_absolute_path_reanchored_under_root() {
let handler = SftpHandler::new(
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler.resolve_path("/file.bin").unwrap();
assert_eq!(resolved, PathBuf::from("/home/work/file.bin"));
}
#[test]
fn sftp_chroot_absolute_host_path_confined_under_root() {
let handler = SftpHandler::new(
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler.resolve_path("/etc/passwd").unwrap();
assert_eq!(resolved, PathBuf::from("/home/work/etc/passwd"));
}
#[test]
fn sftp_chroot_relative_traversal_clamped() {
let handler = SftpHandler::new(
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler.resolve_path("../../etc/passwd").unwrap();
assert!(resolved.starts_with("/home/work"));
assert_eq!(resolved, PathBuf::from("/home/work/etc/passwd"));
}
#[test]
fn sftp_chroot_root_path_returns_chroot() {
let handler = SftpHandler::new(
user(),
Some(PathBuf::from("/home/work")),
PathBuf::from("/home/work"),
);
let resolved = handler.resolve_path("/").unwrap();
assert_eq!(resolved, PathBuf::from("/home/work"));
}
#[test]
#[cfg(unix)]
fn scp_chroot_blocks_symlink_escape() {
let dir = tempdir().expect("tempdir");
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside_target = dir.path().join("outside.txt");
std::fs::write(&outside_target, b"secret").unwrap();
let escape_link = chroot.join("escape");
std::os::unix::fs::symlink(&outside_target, &escape_link).unwrap();
let handler = ScpHandler::new(
ScpMode::Source,
escape_link.clone(),
user(),
Some(chroot.clone()),
chroot.clone(),
);
let err = handler
.resolve_path(Path::new("/escape"))
.expect_err("symlink escape must be blocked");
assert!(
err.to_string().contains("symlink target outside root"),
"expected symlink-escape error, got: {err}"
);
}
#[test]
#[cfg(unix)]
fn scp_chroot_blocks_relative_symlink_escape() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside_target = dir.path().join("outside.txt");
std::fs::write(&outside_target, b"secret").unwrap();
std::os::unix::fs::symlink(&outside_target, chroot.join("link")).unwrap();
let handler = ScpHandler::new(
ScpMode::Source,
PathBuf::from("link"),
user(),
Some(chroot.clone()),
chroot.clone(),
);
let err = handler
.resolve_path(Path::new("link"))
.expect_err("relative symlink escape must be blocked");
assert!(err.to_string().contains("symlink target outside root"));
}
#[test]
#[cfg(unix)]
fn scp_chroot_blocks_parent_symlink_create() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, chroot.join("escape")).unwrap();
let target = chroot.join("escape").join("newfile.txt");
let handler = ScpHandler::new(
ScpMode::Sink,
target.clone(),
user(),
Some(chroot.clone()),
chroot.clone(),
);
let err = handler
.resolve_path(Path::new("/escape/newfile.txt"))
.expect_err("parent-symlink escape must be blocked");
assert!(
err.to_string().contains("outside root"),
"expected access-denied error, got: {err}"
);
}
#[test]
#[cfg(unix)]
fn sftp_chroot_blocks_parent_symlink_create() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, chroot.join("escape")).unwrap();
let handler = SftpHandler::new(user(), Some(chroot.clone()), chroot.clone());
let err = handler
.resolve_path("/escape/newfile.txt")
.expect_err("parent-symlink escape must be blocked");
assert!(
err.to_string().contains("outside root"),
"expected permission-denied, got: {err}"
);
}
#[test]
#[cfg(unix)]
fn sftp_chroot_blocks_parent_symlink_mkdir() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, chroot.join("escape")).unwrap();
let handler = SftpHandler::new(user(), Some(chroot.clone()), chroot.clone());
let err = handler
.resolve_path("/escape/newdir")
.expect_err("parent-symlink mkdir-target must be blocked");
assert!(err.to_string().contains("outside root"));
}
#[test]
#[cfg(unix)]
fn scp_chroot_blocks_relative_through_parent_symlink() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, chroot.join("escape")).unwrap();
let handler = ScpHandler::new(
ScpMode::Sink,
PathBuf::from("escape/newfile.txt"),
user(),
Some(chroot.clone()),
chroot.clone(),
);
let err = handler
.resolve_path(Path::new("escape/newfile.txt"))
.expect_err("relative parent-symlink escape must be blocked");
assert!(err.to_string().contains("outside root"));
}
#[test]
#[cfg(unix)]
fn scp_chroot_allows_legitimate_nested_create() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
std::fs::create_dir(chroot.join("subdir")).unwrap();
let target = chroot.join("subdir").join("legit.txt");
let handler = ScpHandler::new(
ScpMode::Sink,
target.clone(),
user(),
Some(chroot.clone()),
chroot.clone(),
);
let resolved = handler
.resolve_path(&target)
.expect("legitimate nested create should resolve");
assert!(resolved.starts_with(&chroot));
}
#[test]
#[cfg(unix)]
fn sftp_chroot_allows_create_in_nonexistent_subdir() {
let dir = tempdir().unwrap();
let chroot = dir.path().join("chroot");
std::fs::create_dir(&chroot).unwrap();
let handler = SftpHandler::new(user(), Some(chroot.clone()), chroot.clone());
let target_str = format!("{}/will-be-created/newfile.txt", chroot.display());
let resolved = handler
.resolve_path(&target_str)
.expect("nested non-existent subdir should resolve");
assert!(resolved.starts_with(&chroot));
}