#![cfg(unix)]
use std::fs;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use super::client::SftpClient;
use super::server::{SftpServerOptions, SftpServerSession};
use super::types::{Attrs, FXF_CREAT, FXF_READ, FXF_TRUNC, FXF_WRITE, FxpStatus, SftpError};
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"puressh-sftp-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn pair() -> (UnixStream, UnixStream) {
UnixStream::pair().unwrap()
}
fn spawn_server(opts: SftpServerOptions, server_end: UnixStream) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut session = SftpServerSession::new(opts);
session.run(server_end).expect("sftp server session");
})
}
#[test]
fn version_handshake() {
let tmp = TempDir::new("handshake");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let client = SftpClient::new(b).unwrap();
assert!(client.server_version() >= 3);
drop(client);
h.join().unwrap();
}
#[test]
fn open_write_read_close() {
let tmp = TempDir::new("rw");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let handle = client
.open(
b"hello.txt",
FXF_WRITE | FXF_CREAT | FXF_TRUNC,
Attrs::default(),
)
.unwrap();
client.write(&handle, 0, b"hello world").unwrap();
client.close(&handle).unwrap();
let handle = client
.open(b"hello.txt", FXF_READ, Attrs::default())
.unwrap();
let data = client.read(&handle, 0, 1024).unwrap();
assert_eq!(data, b"hello world");
client.close(&handle).unwrap();
let on_disk = fs::read(tmp.path().join("hello.txt")).unwrap();
assert_eq!(on_disk, b"hello world");
drop(client);
h.join().unwrap();
}
#[test]
fn stat_returns_size_and_mode() {
let tmp = TempDir::new("stat");
fs::write(tmp.path().join("a.txt"), b"abc").unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let attrs = client.stat(b"a.txt").unwrap();
assert_eq!(attrs.size, Some(3));
assert!(attrs.permissions.unwrap_or(0) != 0);
drop(client);
h.join().unwrap();
}
#[test]
fn mkdir_readdir_rmdir() {
let tmp = TempDir::new("dir");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
client.mkdir(b"sub", Attrs::default()).unwrap();
let handle = client.opendir(b".").unwrap();
let entries = client.readdir(&handle).unwrap().unwrap();
assert!(entries.iter().any(|e| e.filename == b"sub"));
let mut saw_none = false;
for _ in 0..16 {
match client.readdir(&handle).unwrap() {
None => {
saw_none = true;
break;
}
Some(_) => continue,
}
}
assert!(saw_none, "expected directory iteration to end");
client.close(&handle).unwrap();
client.rmdir(b"sub").unwrap();
assert!(!tmp.path().join("sub").exists());
drop(client);
h.join().unwrap();
}
#[test]
fn rename_and_remove() {
let tmp = TempDir::new("rename");
fs::write(tmp.path().join("a"), b"x").unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
client.rename(b"a", b"b").unwrap();
assert!(tmp.path().join("b").exists());
client.remove(b"b").unwrap();
assert!(!tmp.path().join("b").exists());
drop(client);
h.join().unwrap();
}
#[test]
fn realpath_normalises() {
let tmp = TempDir::new("realpath");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let p = client.realpath(b"./foo/../bar").unwrap();
let expected = tmp.path().join("bar").to_string_lossy().into_owned();
assert_eq!(String::from_utf8_lossy(&p), expected);
drop(client);
h.join().unwrap();
}
#[test]
fn symlink_and_readlink() {
let tmp = TempDir::new("symlink");
fs::write(tmp.path().join("target"), b"x").unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
client.symlink(b"target", b"linkname").unwrap();
let tgt = client.readlink(b"linkname").unwrap();
assert_eq!(tgt, b"target");
drop(client);
h.join().unwrap();
}
#[test]
fn jail_blocks_traversal_escape() {
let tmp = TempDir::new("jail");
let jail = tmp.path().to_path_buf();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client
.open(b"../../etc/passwd", FXF_READ, Attrs::default())
.unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected PermissionDenied, got {other:?}"),
}
drop(client);
h.join().unwrap();
}
#[test]
fn read_only_refuses_writes() {
let tmp = TempDir::new("readonly");
let opts = SftpServerOptions::new(tmp.path()).read_only();
let (a, b) = pair();
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client
.open(b"new.txt", FXF_WRITE | FXF_CREAT, Attrs::default())
.unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected PermissionDenied, got {other:?}"),
}
drop(client);
h.join().unwrap();
}
#[test]
fn fstat_after_open() {
let tmp = TempDir::new("fstat");
fs::write(tmp.path().join("x"), b"hello").unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let handle = client.open(b"x", FXF_READ, Attrs::default()).unwrap();
let attrs = client.fstat(&handle).unwrap();
assert_eq!(attrs.size, Some(5));
client.close(&handle).unwrap();
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_open_through_planted_symlink_rejected() {
let tmp = TempDir::new("symjail-open");
let jail = tmp.path().to_path_buf();
std::os::unix::fs::symlink("/etc/passwd", jail.join("escape")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client
.open(b"escape", FXF_READ, Attrs::default())
.unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_setstat_through_symlink_rejected() {
let tmp = TempDir::new("symjail-setstat");
let jail = tmp.path().to_path_buf();
let outside = std::env::temp_dir().join(format!(
"puressh-sftp-victim-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
fs::write(&outside, b"victim").unwrap();
let _g = OutsideGuard(outside.clone());
std::os::unix::fs::symlink(&outside, jail.join("link")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let attrs = Attrs {
permissions: Some(0o000),
..Default::default()
};
let err = client.setstat(b"link", attrs).unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
let md = fs::metadata(&outside).unwrap();
use std::os::unix::fs::PermissionsExt as _;
assert_ne!(md.permissions().mode() & 0o777, 0o000);
drop(client);
h.join().unwrap();
}
fn outside_victim(tag: &str, contents: &[u8]) -> (PathBuf, OutsideGuard) {
let outside = std::env::temp_dir().join(format!(
"puressh-sftp-victim-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
fs::write(&outside, contents).unwrap();
let guard = OutsideGuard(outside.clone());
(outside, guard)
}
#[test]
fn jailed_remove_through_symlink_rejected() {
let tmp = TempDir::new("symjail-remove");
let jail = tmp.path().to_path_buf();
let (outside, _g) = outside_victim("remove", b"victim");
std::os::unix::fs::symlink(&outside, jail.join("link")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client.remove(b"link").unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
assert!(outside.exists());
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_rmdir_through_symlink_rejected() {
let tmp = TempDir::new("symjail-rmdir");
let jail = tmp.path().to_path_buf();
let (outside, _g) = outside_victim("rmdir", b"");
fs::remove_file(&outside).unwrap();
fs::create_dir(&outside).unwrap();
let _gd = OutsideDirGuard(outside.clone());
std::os::unix::fs::symlink(&outside, jail.join("dlink")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client.rmdir(b"dlink").unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
assert!(outside.is_dir());
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_rename_through_symlink_rejected() {
let tmp = TempDir::new("symjail-rename");
let jail = tmp.path().to_path_buf();
let (outside, _g) = outside_victim("rename", b"victim");
std::os::unix::fs::symlink(&outside, jail.join("from")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client.rename(b"from", b"to").unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
assert!(outside.exists());
assert!(!jail.join("to").exists());
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_open_relative_symlink_to_outside_rejected() {
let tmp = TempDir::new("symjail-rel");
let jail = tmp.path().to_path_buf();
std::os::unix::fs::symlink("../../etc/passwd", jail.join("trap")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client
.open(b"trap", FXF_READ, Attrs::default())
.unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
}
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_symlink_with_absolute_target_rejected() {
let tmp = TempDir::new("symjail-abs");
let jail = tmp.path().to_path_buf();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let err = client.symlink(b"/etc/passwd", b"abs-link").unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected PermissionDenied, got {other:?}"),
}
drop(client);
h.join().unwrap();
}
fn expect_denied<T: std::fmt::Debug>(r: Result<T, SftpError>) {
match r {
Err(SftpError::Status { code, .. }) => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected PermissionDenied, got {other:?}"),
}
}
#[test]
fn jailed_symlink_chain_escape_rejected() {
let tmp = TempDir::new("symjail-chain");
let jail = tmp.path().join("jail");
fs::create_dir_all(&jail).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
client.mkdir(b"a", Attrs::default()).unwrap();
expect_denied(client.symlink(b"..", b"a/s"));
expect_denied(client.symlink(b"s/../..", b"a/t"));
expect_denied(client.symlink(b"x/../y", b"a/u"));
assert!(fs::symlink_metadata(jail.join("a/s")).is_err());
assert!(fs::symlink_metadata(jail.join("a/t")).is_err());
assert!(
client
.open(b"a/t/jail", FXF_READ, Attrs::default())
.is_err()
);
client.symlink(b"x/y", b"a/down").unwrap();
client.symlink(b"./z", b"a/dot").unwrap();
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_symlink_then_rename_escape_rejected() {
let tmp = TempDir::new("symjail-rename-chain");
let jail = tmp.path().join("jail");
fs::create_dir_all(&jail).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
client.mkdir(b"a", Attrs::default()).unwrap();
client.mkdir(b"a/b", Attrs::default()).unwrap();
expect_denied(client.symlink(b"../..", b"a/b/s"));
client.rename(b"a/b", b"b").unwrap();
assert!(fs::symlink_metadata(jail.join("b/s")).is_err());
assert!(client.opendir(b"b/s").is_err());
drop(client);
h.join().unwrap();
}
fn expect_permission_denied(r: Result<Vec<u8>, SftpError>) {
match r {
Err(SftpError::Status { code, .. }) => assert_eq!(code, FxpStatus::PermissionDenied),
Err(other) => panic!("expected PermissionDenied, got {other:?}"),
Ok(v) => panic!(
"expected PermissionDenied, got target {:?}",
String::from_utf8_lossy(&v)
),
}
}
#[test]
fn jailed_readlink_absolute_target_outside_jail_denied() {
let tmp = TempDir::new("readlink-jail-abs-out");
let jail = tmp.path().to_path_buf();
std::os::unix::fs::symlink("/etc/passwd", jail.join("escape")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
expect_permission_denied(client.readlink(b"escape"));
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_readlink_relative_escape_denied() {
let tmp = TempDir::new("readlink-jail-rel-out");
let jail = tmp.path().to_path_buf();
fs::create_dir_all(jail.join("sub")).unwrap();
std::os::unix::fs::symlink("../../etc/passwd", jail.join("sub/trap")).unwrap();
std::os::unix::fs::symlink("../../../../../../x", jail.join("deep")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
expect_permission_denied(client.readlink(b"sub/trap"));
expect_permission_denied(client.readlink(b"deep"));
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_readlink_absolute_target_inside_jail_rewritten() {
let tmp = TempDir::new("readlink-jail-abs-in");
let jail = tmp.path().to_path_buf();
fs::create_dir_all(jail.join("sub")).unwrap();
fs::write(jail.join("sub/file"), b"x").unwrap();
std::os::unix::fs::symlink(jail.join("sub/file"), jail.join("abs")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let tgt = client.readlink(b"abs").unwrap();
assert_eq!(String::from_utf8_lossy(&tgt), "/sub/file");
drop(client);
h.join().unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone())
.with_root(jail.clone())
.hide_jail_in_realpath(false);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let tgt = client.readlink(b"abs").unwrap();
assert_eq!(
String::from_utf8_lossy(&tgt),
jail.join("sub/file").to_string_lossy()
);
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_readlink_relative_target_inside_jail_verbatim() {
let tmp = TempDir::new("readlink-jail-rel-in");
let jail = tmp.path().to_path_buf();
fs::create_dir_all(jail.join("sub")).unwrap();
std::os::unix::fs::symlink("../sub/file", jail.join("sub/rel")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let tgt = client.readlink(b"sub/rel").unwrap();
assert_eq!(String::from_utf8_lossy(&tgt), "../sub/file");
drop(client);
h.join().unwrap();
}
#[test]
fn unjailed_readlink_returns_absolute_target_verbatim() {
let tmp = TempDir::new("readlink-nojail");
let dir = tmp.path().to_path_buf();
std::os::unix::fs::symlink("/etc/passwd", dir.join("abs")).unwrap();
let (a, b) = pair();
let opts = SftpServerOptions::new(dir);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let tgt = client.readlink(b"abs").unwrap();
assert_eq!(String::from_utf8_lossy(&tgt), "/etc/passwd");
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_realpath_strips_jail_prefix_when_opted_in() {
let tmp = TempDir::new("realpath-jail");
let jail = tmp.path().to_path_buf();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone())
.with_root(jail.clone())
.hide_jail_in_realpath(true);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let p = client.realpath(b".").unwrap();
let s = String::from_utf8_lossy(&p);
assert!(
!s.contains(jail.to_string_lossy().as_ref()),
"jail leaked in realpath: {s}"
);
assert_eq!(s, "/", "expected '/' inside jail, got {s}");
let p = client.realpath(b"sub/file").unwrap();
assert_eq!(String::from_utf8_lossy(&p), "/sub/file");
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_realpath_hides_jail_prefix_by_default() {
let tmp = TempDir::new("realpath-jail-default");
let jail = tmp.path().to_path_buf();
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let p = client.realpath(b".").unwrap();
let s = String::from_utf8_lossy(&p).to_string();
assert_eq!(s, "/", "default should hide jail prefix in realpath");
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_realpath_leaks_when_explicit_opt_out() {
let tmp = TempDir::new("realpath-jail-leak-optout");
let jail = tmp.path().to_path_buf();
let canon_jail = std::fs::canonicalize(&jail).unwrap_or_else(|_| jail.clone());
let (a, b) = pair();
let opts = SftpServerOptions::new(jail.clone())
.with_root(jail.clone())
.hide_jail_in_realpath(false);
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let p = client.realpath(b".").unwrap();
let s = String::from_utf8_lossy(&p).to_string();
assert!(
s == jail.to_string_lossy() || s == canon_jail.to_string_lossy(),
"expected unstripped jail path with opt-out, got {s}"
);
drop(client);
h.join().unwrap();
}
#[test]
fn setstat_set_len_above_cap_rejected() {
let tmp = TempDir::new("setlen");
fs::write(tmp.path().join("f"), b"x").unwrap();
let opts = SftpServerOptions::new(tmp.path()).with_max_set_len(1024);
let (a, b) = pair();
let h = spawn_server(opts, a);
let mut client = SftpClient::new(b).unwrap();
let attrs = Attrs {
size: Some(8 * 1024), ..Default::default()
};
let err = client.setstat(b"f", attrs).unwrap_err();
match err {
SftpError::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected PermissionDenied, got {other:?}"),
}
assert_eq!(fs::read(tmp.path().join("f")).unwrap(), b"x");
drop(client);
h.join().unwrap();
}
#[test]
fn open_strips_setuid_bit_by_default() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = TempDir::new("setuid");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let attrs = Attrs {
permissions: Some(0o4755),
..Default::default()
};
let handle = client
.open(b"suid.bin", FXF_WRITE | FXF_CREAT, attrs)
.unwrap();
client.close(&handle).unwrap();
let md = fs::metadata(tmp.path().join("suid.bin")).unwrap();
assert_eq!(
md.permissions().mode() & 0o7777,
0o0755,
"setuid should be stripped by default"
);
drop(client);
h.join().unwrap();
}
#[test]
fn setstat_strips_special_bits_by_default() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = TempDir::new("setstat-special");
fs::write(tmp.path().join("f"), b"x").unwrap();
fs::set_permissions(tmp.path().join("f"), fs::Permissions::from_mode(0o644)).unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let attrs = Attrs {
permissions: Some(0o6755), ..Default::default()
};
client.setstat(b"f", attrs).unwrap();
let md = fs::metadata(tmp.path().join("f")).unwrap();
assert_eq!(md.permissions().mode() & 0o7777, 0o0755);
drop(client);
h.join().unwrap();
}
struct OutsideGuard(PathBuf);
impl Drop for OutsideGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
struct OutsideDirGuard(PathBuf);
impl Drop for OutsideDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn large_file_round_trip() {
let tmp = TempDir::new("large");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let handle = client
.open(b"big", FXF_WRITE | FXF_CREAT | FXF_TRUNC, Attrs::default())
.unwrap();
let chunk = vec![0xab_u8; 32 * 1024];
let total_chunks = 4_u64;
for i in 0..total_chunks {
client
.write(&handle, i * chunk.len() as u64, &chunk)
.unwrap();
}
client.close(&handle).unwrap();
let handle = client.open(b"big", FXF_READ, Attrs::default()).unwrap();
let mut got = Vec::new();
let mut offset = 0u64;
loop {
let buf = client.read(&handle, offset, 32 * 1024).unwrap();
if buf.is_empty() {
break;
}
offset += buf.len() as u64;
got.extend_from_slice(&buf);
}
client.close(&handle).unwrap();
assert_eq!(got.len() as u64, total_chunks * chunk.len() as u64);
assert!(got.iter().all(|&b| b == 0xab));
drop(client);
h.join().unwrap();
}
use super::packet::{Packet, read_packet, write_packet};
fn handshake(t: &mut UnixStream) -> Vec<(Vec<u8>, Vec<u8>)> {
write_packet(
t,
&Packet::Init {
version: super::types::SFTP_VERSION,
extensions: vec![],
},
)
.unwrap();
let body = read_packet(t).unwrap();
match Packet::decode(&body).unwrap() {
Packet::Version { extensions, .. } => extensions,
other => panic!("expected VERSION, got {other:?}"),
}
}
fn ext_strings(parts: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
for p in parts {
out.extend_from_slice(&(p.len() as u32).to_be_bytes());
out.extend_from_slice(p);
}
out
}
fn ext_request(t: &mut UnixStream, request: &[u8], payload: &[u8]) -> Packet {
let pkt = Packet::Extended {
id: 42,
request: request.to_vec(),
data: payload.to_vec(),
};
write_packet(t, &pkt).unwrap();
let body = read_packet(t).unwrap();
Packet::decode(&body).unwrap()
}
#[test]
fn version_advertises_openssh_extensions() {
let tmp = TempDir::new("ext-advertise");
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let exts = handshake(&mut b);
let names: Vec<String> = exts
.iter()
.map(|(k, _)| String::from_utf8_lossy(k).into_owned())
.collect();
for want in &[
"posix-rename@openssh.com",
"statvfs@openssh.com",
"fstatvfs@openssh.com",
"hardlink@openssh.com",
"fsync@openssh.com",
] {
assert!(
names.iter().any(|n| n == want),
"missing {want} in advertised extensions: {names:?}"
);
}
drop(b);
h.join().unwrap();
}
#[test]
fn posix_rename_overwrites_destination() {
let tmp = TempDir::new("posix-rename-overwrite");
fs::write(tmp.path().join("a"), b"alpha").unwrap();
fs::write(tmp.path().join("b"), b"beta-original").unwrap();
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"a", b"b"]);
let reply = ext_request(&mut b, b"posix-rename@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::Ok),
other => panic!("expected STATUS, got {other:?}"),
}
assert!(!tmp.path().join("a").exists());
assert_eq!(fs::read(tmp.path().join("b")).unwrap(), b"alpha");
drop(b);
h.join().unwrap();
}
#[test]
fn posix_rename_outside_jail_rejected() {
let tmp = TempDir::new("posix-rename-jail");
let jail = tmp.path().to_path_buf();
fs::write(jail.join("inside"), b"x").unwrap();
let (a, mut b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"inside", b"../../tmp/escape"]);
let reply = ext_request(&mut b, b"posix-rename@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected STATUS, got {other:?}"),
}
drop(b);
h.join().unwrap();
}
#[test]
fn posix_rename_through_symlink_rejected() {
let tmp = TempDir::new("posix-rename-symjail");
let jail = tmp.path().to_path_buf();
let (outside, _g) = outside_victim("posix-rename", b"victim");
std::os::unix::fs::symlink(&outside, jail.join("from")).unwrap();
let (a, mut b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail.clone());
let h = spawn_server(opts, a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"from", b"to"]);
let reply = ext_request(&mut b, b"posix-rename@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected STATUS, got {other:?}"),
}
assert!(outside.exists());
assert!(!jail.join("to").exists());
drop(b);
h.join().unwrap();
}
#[test]
fn hardlink_creates_second_inode_reference() {
use std::os::unix::fs::MetadataExt as _;
let tmp = TempDir::new("hardlink");
fs::write(tmp.path().join("orig"), b"shared").unwrap();
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"orig", b"linked"]);
let reply = ext_request(&mut b, b"hardlink@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::Ok),
other => panic!("expected STATUS, got {other:?}"),
}
let m1 = fs::metadata(tmp.path().join("orig")).unwrap();
let m2 = fs::metadata(tmp.path().join("linked")).unwrap();
assert_eq!(m1.ino(), m2.ino(), "expected same inode after hardlink");
assert!(m2.nlink() >= 2);
drop(b);
h.join().unwrap();
}
#[test]
fn hardlink_outside_jail_rejected() {
let tmp = TempDir::new("hardlink-jail");
let jail = tmp.path().to_path_buf();
fs::write(jail.join("inside"), b"x").unwrap();
let (a, mut b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"inside", b"../../tmp/hardlink-escape"]);
let reply = ext_request(&mut b, b"hardlink@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::PermissionDenied),
other => panic!("expected STATUS, got {other:?}"),
}
drop(b);
h.join().unwrap();
}
#[test]
fn fsync_returns_ok_for_open_handle() {
let tmp = TempDir::new("fsync-ok");
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let open = Packet::Open {
id: 1,
path: b"f".to_vec(),
pflags: FXF_WRITE | FXF_CREAT,
attrs: Attrs::default(),
};
write_packet(&mut b, &open).unwrap();
let body = read_packet(&mut b).unwrap();
let handle = match Packet::decode(&body).unwrap() {
Packet::Handle { handle, .. } => handle,
other => panic!("expected HANDLE, got {other:?}"),
};
let write = Packet::Write {
id: 2,
handle: handle.clone(),
offset: 0,
data: b"some bytes".to_vec(),
};
write_packet(&mut b, &write).unwrap();
let _ = read_packet(&mut b).unwrap();
let payload = ext_strings(&[&handle]);
let reply = ext_request(&mut b, b"fsync@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::Ok),
other => panic!("expected STATUS, got {other:?}"),
}
drop(b);
h.join().unwrap();
}
#[test]
fn fsync_unknown_handle_rejected() {
let tmp = TempDir::new("fsync-bad");
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let fake = 0xdeadbeefu64.to_le_bytes().to_vec();
let payload = ext_strings(&[&fake]);
let reply = ext_request(&mut b, b"fsync@openssh.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::Failure),
other => panic!("expected STATUS, got {other:?}"),
}
drop(b);
h.join().unwrap();
}
#[test]
fn statvfs_returns_filesystem_stats() {
let tmp = TempDir::new("statvfs");
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"."]);
let reply = ext_request(&mut b, b"statvfs@openssh.com", &payload);
let data = match reply {
Packet::ExtendedReply { data, .. } => data,
other => panic!("expected EXTENDED_REPLY, got {other:?}"),
};
assert_eq!(data.len(), 8 * 11, "statvfs reply must be 11 u64s");
let mut namemax_bytes = [0u8; 8];
namemax_bytes.copy_from_slice(&data[80..88]);
let namemax = u64::from_be_bytes(namemax_bytes);
assert!(namemax >= 64, "expected f_namemax >= 64, got {namemax}");
drop(b);
h.join().unwrap();
}
#[test]
fn fstatvfs_returns_filesystem_stats() {
let tmp = TempDir::new("fstatvfs");
fs::write(tmp.path().join("f"), b"x").unwrap();
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let open = Packet::Open {
id: 1,
path: b"f".to_vec(),
pflags: FXF_READ,
attrs: Attrs::default(),
};
write_packet(&mut b, &open).unwrap();
let body = read_packet(&mut b).unwrap();
let handle = match Packet::decode(&body).unwrap() {
Packet::Handle { handle, .. } => handle,
other => panic!("expected HANDLE, got {other:?}"),
};
let payload = ext_strings(&[&handle]);
let reply = ext_request(&mut b, b"fstatvfs@openssh.com", &payload);
let data = match reply {
Packet::ExtendedReply { data, .. } => data,
other => panic!("expected EXTENDED_REPLY, got {other:?}"),
};
assert_eq!(data.len(), 8 * 11);
drop(b);
h.join().unwrap();
}
#[test]
fn unknown_extended_request_returns_op_unsupported() {
let tmp = TempDir::new("ext-unknown");
let (a, mut b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let _ = handshake(&mut b);
let payload = ext_strings(&[b"whatever"]);
let reply = ext_request(&mut b, b"bogus@example.com", &payload);
match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::OpUnsupported),
other => panic!("expected STATUS, got {other:?}"),
}
drop(b);
h.join().unwrap();
}
#[test]
fn pipelined_write_from_and_read_to() {
let tmp = TempDir::new("pipelined");
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let data: Vec<u8> = (0..1_000_003u32).map(|i| (i * 7 + i / 251) as u8).collect();
let handle = client
.open(
b"big.bin",
FXF_WRITE | FXF_CREAT | FXF_TRUNC,
Attrs::default(),
)
.unwrap();
let mut last = 0;
let n = client
.write_from(&handle, 0, 4096, 8, &mut &data[..], |p| last = p)
.unwrap();
client.close(&handle).unwrap();
assert_eq!(n, data.len() as u64);
assert_eq!(last, data.len() as u64);
assert_eq!(fs::read(tmp.path().join("big.bin")).unwrap(), data);
let handle = client.open(b"big.bin", FXF_READ, Attrs::default()).unwrap();
let mut back = Vec::new();
let n = client
.read_to(&handle, 0, 4096, 8, &mut back, |_| {})
.unwrap();
assert_eq!(n, data.len() as u64);
assert_eq!(back, data);
let mut tail = Vec::new();
client
.read_to(&handle, 999_000, 4096, 8, &mut tail, |_| {})
.unwrap();
assert_eq!(tail, data[999_000..]);
client.close(&handle).unwrap();
assert!(client.stat(b"big.bin").is_ok());
drop(client);
h.join().unwrap();
}
#[test]
fn copy_data_and_limits() {
let tmp = TempDir::new("copy-data");
fs::write(tmp.path().join("src"), b"0123456789").unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
assert!(client.has_extension("copy-data"));
assert!(client.has_extension("limits@openssh.com"));
let rh = client.open(b"src", FXF_READ, Attrs::default()).unwrap();
let wh = client
.open(b"dst", FXF_WRITE | FXF_CREAT | FXF_TRUNC, Attrs::default())
.unwrap();
client.copy_data(&rh, 0, 0, &wh, 0).unwrap();
client.copy_data(&rh, 2, 3, &wh, 10).unwrap();
assert!(client.copy_data(&rh, 0, 0, &rh, 0).is_err());
client.close(&rh).unwrap();
client.close(&wh).unwrap();
assert_eq!(fs::read(tmp.path().join("dst")).unwrap(), b"0123456789234");
let limits = client.extended("limits@openssh.com", &[]).unwrap().unwrap();
assert_eq!(limits.len(), 32);
assert!(u64::from_be_bytes(limits[8..16].try_into().unwrap()) > 0);
drop(client);
h.join().unwrap();
}
#[test]
fn copy_data_refuses_aliased_and_unbounded_sources() {
let tmp = TempDir::new("copy-data-alias");
fs::write(tmp.path().join("src"), b"0123456789").unwrap();
fs::hard_link(tmp.path().join("src"), tmp.path().join("alias")).unwrap();
let (a, b) = pair();
let h = spawn_server(SftpServerOptions::new(tmp.path()), a);
let mut client = SftpClient::new(b).unwrap();
let rh = client.open(b"src", FXF_READ, Attrs::default()).unwrap();
let wh = client.open(b"alias", FXF_WRITE, Attrs::default()).unwrap();
assert!(client.copy_data(&rh, 0, 0, &wh, 1).is_err());
client.close(&rh).unwrap();
let zero = client
.open(b"/dev/zero", FXF_READ, Attrs::default())
.unwrap();
assert!(client.copy_data(&zero, 0, 0, &wh, 0).is_err());
client.close(&zero).unwrap();
client.close(&wh).unwrap();
assert_eq!(fs::read(tmp.path().join("src")).unwrap(), b"0123456789");
drop(client);
h.join().unwrap();
}
#[test]
fn jailed_home_lookups_hide_outside_homes() {
let tmp = TempDir::new("home-jail");
let jail = tmp.path().to_path_buf();
let (a, mut b) = pair();
let opts = SftpServerOptions::new(jail.clone()).with_root(jail);
let h = spawn_server(opts, a);
let _ = handshake(&mut b);
let expect_nsf = |reply: Packet| match reply {
Packet::Status { code, .. } => assert_eq!(code, FxpStatus::NoSuchFile),
other => panic!("expected NoSuchFile, got {other:?}"),
};
for user in [&b""[..], b"root", b"no-such-user-puressh-xfer5"] {
expect_nsf(ext_request(
&mut b,
b"home-directory",
&ext_strings(&[user]),
));
let mut p = b"~".to_vec();
p.extend_from_slice(user);
expect_nsf(ext_request(
&mut b,
b"expand-path@openssh.com",
&ext_strings(&[&p]),
));
}
drop(b);
h.join().unwrap();
}
#[test]
fn read_to_bounds_readahead_when_first_range_trickles() {
const CHUNK: u32 = 64;
const DEPTH: usize = 4;
const SIZE: u64 = 64 * 1000;
let (a, mut b) = pair();
let server = thread::spawn(move || {
let _ = read_packet(&mut b).unwrap();
write_packet(
&mut b,
&Packet::Version {
version: 3,
extensions: Vec::new(),
},
)
.unwrap();
let mut first_done = false;
let mut max_ahead = 0u64;
while let Ok(body) = read_packet(&mut b) {
let Packet::Read {
id, offset, len, ..
} = Packet::decode(&body).unwrap()
else {
panic!("expected READ");
};
if !first_done {
max_ahead = max_ahead.max(offset);
}
let reply = if offset >= SIZE {
Packet::Status {
id,
code: FxpStatus::Eof,
message: Vec::new(),
lang: Vec::new(),
}
} else {
let n = if offset < u64::from(CHUNK) {
first_done |= offset + 1 == u64::from(CHUNK);
1
} else {
len as usize
};
Packet::Data {
id,
data: vec![b'x'; n],
}
};
write_packet(&mut b, &reply).unwrap();
}
max_ahead
});
let mut client = SftpClient::new(a).unwrap();
let mut sink = Vec::new();
let n = client
.read_to(b"h", 0, CHUNK, DEPTH, &mut sink, |_| {})
.unwrap();
assert_eq!(n, SIZE);
drop(client);
let max_ahead = server.join().unwrap();
assert!(
max_ahead <= (DEPTH as u64 + 1) * u64::from(CHUNK),
"client read {max_ahead} bytes ahead of the sink"
);
}