#![forbid(unsafe_code)]
use crate::errors::{SshCliError, SshCliResult};
pub(crate) const SCP_OK: u8 = 0;
pub(crate) fn basename_scp(file_name: &str) -> String {
file_name
.split(['/', '\\'])
.next_back()
.unwrap_or("file")
.replace(['\n', '\r', '\0'], "_")
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn format_scp_upload_header(size: u64, file_name: &str) -> String {
format_scp_upload_header_with_mode(0o644, size, file_name)
}
pub(crate) fn format_scp_upload_header_with_mode(mode: u32, size: u64, file_name: &str) -> String {
let name = basename_scp(file_name);
let mode = mode & 0o7777;
format!("C{mode:04o} {size} {name}\n")
}
pub(crate) fn format_scp_t_line(mtime_secs: u64, atime_secs: u64) -> String {
format!("T{mtime_secs} 0 {atime_secs} 0\n")
}
pub(crate) fn parse_scp_t_line(line: &str) -> SshCliResult<(u64, u64)> {
let line = line.trim_end_matches(['\0', '\r', '\n']).trim();
if !line.starts_with('T') {
return Err(SshCliError::channel_msg(format!(
"unexpected SCP T line: {line}"
)));
}
let resto = &line[1..];
let partes: Vec<&str> = resto.split_whitespace().collect();
if partes.len() < 3 {
return Err(SshCliError::channel_msg(format!(
"malformed SCP T line: {line}"
)));
}
let mtime: u64 = partes[0].parse().map_err(|_| {
SshCliError::channel_msg(format!("invalid mtime in T line: {}", partes[0]))
})?;
let atime: u64 = partes[2].parse().map_err(|_| {
SshCliError::channel_msg(format!("invalid atime in T line: {}", partes[2]))
})?;
Ok((mtime, atime))
}
pub(crate) fn parse_scp_header(header: &str) -> SshCliResult<(u32, u64)> {
let header = header.trim_end_matches(['\0', '\r', '\n']).trim();
if !header.starts_with('C') {
return Err(SshCliError::channel_msg(format!(
"unexpected SCP header: {}",
header
)));
}
let partes: Vec<&str> = header.split_whitespace().collect();
if partes.len() < 3 {
return Err(SshCliError::channel_msg(format!(
"malformed SCP header: {}",
header
)));
}
let mode_token = partes[0];
if mode_token.len() < 2 {
return Err(SshCliError::channel_msg(format!(
"missing SCP mode in header: {header}"
)));
}
let mode_oct = &mode_token[1..];
let mode: u32 = u32::from_str_radix(mode_oct, 8)
.map_err(|_| SshCliError::channel_msg(format!("invalid SCP mode: {mode_oct}")))?;
let size = partes[1].parse().map_err(|_| {
SshCliError::channel_msg(format!("invalid size in header: {}", partes[1]))
})?;
Ok((mode & 0o7777, size))
}
pub(crate) fn scp_mode_from_metadata(meta: &std::fs::Metadata) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
{
let _ = meta;
0o644
}
}
pub(crate) fn system_time_secs(t: std::time::SystemTime) -> u64 {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) const SCP_PARTIAL_SUFFIX: &str = ".ssh-cli.partial";
pub(crate) fn partial_download_path(local: &std::path::Path) -> std::path::PathBuf {
let mut p = local.as_os_str().to_os_string();
p.push(SCP_PARTIAL_SUFFIX);
std::path::PathBuf::from(p)
}
pub(crate) fn classify_scp_message(msg: &str) -> SshCliError {
let lower = msg.to_ascii_lowercase();
if lower.contains("no such file") || lower.contains("not found") {
SshCliError::FileNotFound(normalize_scp_missing_path(msg))
} else if msg.is_empty() {
SshCliError::channel_msg("SCP rejected the transfer")
} else if msg.starts_with("SCP:") || msg.starts_with("SCP ") {
SshCliError::channel_msg(msg)
} else {
SshCliError::channel_msg(format!("SCP: {msg}"))
}
}
pub(crate) fn normalize_scp_missing_path(msg: &str) -> String {
let mut s = msg.trim().to_string();
for prefix in ["SCP: ", "SCP:", "scp: ", "scp:"] {
if let Some(rest) = s.strip_prefix(prefix) {
s = rest.trim().to_string();
}
}
let lower = s.to_ascii_lowercase();
for needle in [": no such file or directory", ": not found"] {
if let Some(idx) = lower.find(needle) {
return s[..idx].trim().trim_matches('"').to_string();
}
}
s
}
pub(crate) fn interpret_scp_status(bytes: &[u8]) -> SshCliResult<()> {
if bytes.is_empty() {
return Err(SshCliError::channel_msg(
"empty SCP status (expected ACK 0x00)",
));
}
match bytes[0] {
SCP_OK => Ok(()),
1 | 2 => {
let msg = String::from_utf8_lossy(&bytes[1..]).trim().to_string();
if msg.is_empty() {
Err(SshCliError::channel_msg(format!(
"SCP rejected the transfer (status {})",
bytes[0]
)))
} else {
let full = format!("SCP: {msg}");
Err(classify_scp_message(&full))
}
}
other => Err(SshCliError::channel_msg(format!(
"unexpected SCP status: 0x{other:02x}"
))),
}
}
pub(crate) fn remote_scp_command(mode: &str, remote: &std::path::Path) -> String {
let path = crate::ssh::packing::escape_shell_single_quotes(&remote.display().to_string());
let mode_p = if mode.contains('p') {
mode.to_string()
} else {
format!("{mode}p")
};
format!("scp {mode_p} {path}")
}
pub(crate) async fn apply_local_mode(path: &std::path::Path, mode: u32) -> SshCliResult<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode & 0o7777);
tokio::fs::set_permissions(path, perms)
.await
.map_err(SshCliError::Io)?;
}
#[cfg(not(unix))]
{
let _ = (path, mode);
}
Ok(())
}
pub(crate) async fn scp_read_data<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
where
S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
{
use russh::ChannelMsg;
loop {
if crate::signals::should_stop() {
return Err(SshCliError::Config(
"operation cancelled by signal".to_string(),
));
}
match channel.wait().await {
Some(ChannelMsg::Data { data }) => {
if data.is_empty() {
continue;
}
return Ok(data.to_vec());
}
Some(ChannelMsg::ExtendedData { data, .. }) => {
if data.is_empty() {
continue;
}
let msg = String::from_utf8_lossy(data.as_ref()).trim().to_string();
let full = format!("SCP stderr: {msg}");
return Err(classify_scp_message(&full));
}
Some(ChannelMsg::ExitStatus { exit_status }) if exit_status != 0 => {
return Err(SshCliError::channel_msg(format!(
"scp exited with status {exit_status}"
)));
}
Some(ChannelMsg::Close) | None => {
return Err(SshCliError::channel_msg(
"SCP channel closed prematurely",
));
}
_ => continue,
}
}
}
pub(crate) async fn scp_wait_status<S>(channel: &mut russh::Channel<S>) -> SshCliResult<()>
where
S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
{
let data = scp_read_data(channel).await?;
interpret_scp_status(&data)
}
pub(crate) async fn scp_read_until_newline<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
where
S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
{
let mut buf = Vec::with_capacity(256);
loop {
let chunk = scp_read_data(channel).await?;
if buf.is_empty() && matches!(chunk.first().copied(), Some(1 | 2)) {
return Ok(chunk);
}
buf.extend_from_slice(&chunk);
if buf.contains(&b'\n') {
return Ok(buf);
}
if buf.len() > 16_384 {
return Err(SshCliError::channel_msg(
"SCP header excessively long",
));
}
}
}
#[cfg(test)]
mod wire_adversarial_tests {
use super::*;
#[test]
fn parse_header_adversarial_no_panic() {
for s in ["", "C", "Cxxxx", "T", "T1", "\0\n", "C0644 not_a_size name\n", "C9999 1 x\n"] {
let _ = parse_scp_header(s);
let _ = parse_scp_t_line(s);
let _ = interpret_scp_status(s.as_bytes());
}
}
}