feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -0,0 +1,176 @@
|
||||
//! One-shot remote host snapshot for the status bar (active SSH tab only).
|
||||
|
||||
use crate::domain::ssh::{HostDiskMetric, HostMetrics};
|
||||
|
||||
/// POSIX-ish script: timezone (IANA + offset), CPU%, mem%, disk top3.
|
||||
/// Uses `/proc` when present; `sleep 1` for a cheap CPU delta sample.
|
||||
pub const HOST_METRICS_SCRIPT: &str = r#"
|
||||
ZONE=$(timedatectl show -p Timezone --value 2>/dev/null \
|
||||
|| cat /etc/timezone 2>/dev/null \
|
||||
|| readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||' \
|
||||
|| echo -)
|
||||
OFF=$(date +%z 2>/dev/null || echo -)
|
||||
CPU=-
|
||||
if [ -r /proc/stat ]; then
|
||||
# Aggregate "cpu " line = sum of all cores (same as top/htop summary).
|
||||
# busy% = 100 * (total - idle - iowait) / total → always 0–100, not N×100.
|
||||
set -- $(awk '/^cpu /{
|
||||
idle=$5; iow=$6; total=0;
|
||||
for (i=2;i<=NF;i++) total+=$i;
|
||||
print total, idle+iow; exit
|
||||
}' /proc/stat)
|
||||
T1=$1; I1=$2
|
||||
sleep 1
|
||||
set -- $(awk '/^cpu /{
|
||||
idle=$5; iow=$6; total=0;
|
||||
for (i=2;i<=NF;i++) total+=$i;
|
||||
print total, idle+iow; exit
|
||||
}' /proc/stat)
|
||||
T2=$1; I2=$2
|
||||
DT=$((T2-T1)); DI=$((I2-I1))
|
||||
if [ "$DT" -gt 0 ] 2>/dev/null; then
|
||||
BUSY=$((DT-DI))
|
||||
if [ "$BUSY" -lt 0 ] 2>/dev/null; then BUSY=0; fi
|
||||
CPU=$(awk -v busy="$BUSY" -v dt="$DT" 'BEGIN {
|
||||
v = 100.0 * busy / dt
|
||||
if (v < 0) v = 0
|
||||
if (v > 100) v = 100
|
||||
printf "%.1f", v
|
||||
}')
|
||||
fi
|
||||
fi
|
||||
MEM_PCT=-; MEM_USED=-; MEM_TOTAL=-
|
||||
if [ -r /proc/meminfo ]; then
|
||||
MT=$(awk '/^MemTotal:/{print $2; exit}' /proc/meminfo)
|
||||
MA=$(awk '/^MemAvailable:/{print $2; exit}' /proc/meminfo)
|
||||
if [ -n "$MT" ] && [ "$MT" -gt 0 ] 2>/dev/null && [ -n "$MA" ]; then
|
||||
MU=$((MT-MA))
|
||||
MEM_USED=$MU
|
||||
MEM_TOTAL=$MT
|
||||
MEM_PCT=$(( (100*MU)/MT ))
|
||||
fi
|
||||
fi
|
||||
DISK=$(df -Pk 2>/dev/null | awk 'NR>1 && $1!~/^(tmpfs|devtmpfs|overlay|squashfs|udev|proc|sysfs)/ && $6!~/^\/(dev|run|sys|proc)/ {
|
||||
p=$5; gsub(/%/,"",p); printf "%d %s %s %s\n", p+0, $6, $3, $2
|
||||
}' | sort -nr | head -3 | awk '{printf "%s%s:%s:%s:%s", (NR>1?",":""), $2,$1,$3,$4}')
|
||||
OS=$(awk -F= '/^PRETTY_NAME=/{
|
||||
v=$0; sub(/^[^=]*=/, "", v); gsub(/^"/, "", v); gsub(/"$/, "", v); print v; exit
|
||||
}' /etc/os-release 2>/dev/null || echo -)
|
||||
printf "os=%s\ntz=%s\noff=%s\ncpu=%s\nmem_used=%s\nmem_total=%s\nmem_pct=%s\ndisk=%s\n" \
|
||||
"$OS" "$ZONE" "$OFF" "$CPU" "$MEM_USED" "$MEM_TOTAL" "$MEM_PCT" "$DISK"
|
||||
"#;
|
||||
|
||||
pub fn parse_host_metrics(raw: &str) -> HostMetrics {
|
||||
let mut os_pretty_name = None;
|
||||
let mut tz = None;
|
||||
let mut tz_offset = None;
|
||||
let mut cpu_percent = None;
|
||||
let mut mem_used_kb = None;
|
||||
let mut mem_total_kb = None;
|
||||
let mut mem_percent = None;
|
||||
let mut disks = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value == "-" {
|
||||
continue;
|
||||
}
|
||||
match key {
|
||||
"os" => os_pretty_name = Some(value.to_string()),
|
||||
"tz" => tz = Some(value.to_string()),
|
||||
"off" => tz_offset = Some(value.to_string()),
|
||||
"cpu" => {
|
||||
cpu_percent = value.parse::<f32>().ok().map(|v| {
|
||||
if v.is_finite() {
|
||||
(v.clamp(0.0, 100.0) * 10.0).round() / 10.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
}
|
||||
"mem_used" => mem_used_kb = value.parse().ok(),
|
||||
"mem_total" => mem_total_kb = value.parse().ok(),
|
||||
"mem_pct" => mem_percent = value.parse::<u8>().ok().map(|v| v.min(100)),
|
||||
"disk" => {
|
||||
disks = value
|
||||
.split(',')
|
||||
.filter_map(|entry| {
|
||||
let mut parts = entry.split(':');
|
||||
let mount = parts.next()?.to_string();
|
||||
if mount.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let percent = parts.next()?.parse::<u8>().ok()?.min(100);
|
||||
let used_kb = parts.next()?.parse().ok()?;
|
||||
let total_kb = parts.next()?.parse().ok()?;
|
||||
Some(HostDiskMetric {
|
||||
mount,
|
||||
percent,
|
||||
used_kb,
|
||||
total_kb,
|
||||
})
|
||||
})
|
||||
.take(3)
|
||||
.collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
HostMetrics {
|
||||
os_pretty_name,
|
||||
timezone: tz,
|
||||
timezone_offset: tz_offset,
|
||||
uptime_sec: None,
|
||||
cpu_percent,
|
||||
mem_used_kb,
|
||||
mem_total_kb,
|
||||
mem_percent,
|
||||
disks,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_full_snapshot() {
|
||||
let raw = "\
|
||||
os=Ubuntu 24.04.4 LTS
|
||||
tz=Asia/Shanghai
|
||||
off=+0800
|
||||
cpu=23.4
|
||||
mem_used=4096000
|
||||
mem_total=8192000
|
||||
mem_pct=50
|
||||
disk=/:78:1000:2000,/data:45:500:1000,/var:30:100:400
|
||||
";
|
||||
let m = parse_host_metrics(raw);
|
||||
assert_eq!(m.os_pretty_name.as_deref(), Some("Ubuntu 24.04.4 LTS"));
|
||||
assert_eq!(m.timezone.as_deref(), Some("Asia/Shanghai"));
|
||||
assert_eq!(m.timezone_offset.as_deref(), Some("+0800"));
|
||||
assert_eq!(m.uptime_sec, None);
|
||||
assert_eq!(m.cpu_percent, Some(23.4));
|
||||
assert_eq!(m.mem_percent, Some(50));
|
||||
assert_eq!(m.disks.len(), 3);
|
||||
assert_eq!(m.disks[0].mount, "/");
|
||||
assert_eq!(m.disks[0].percent, 78);
|
||||
assert_eq!(m.disks[1].mount, "/data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_missing_fields() {
|
||||
let m = parse_host_metrics("tz=-\ncpu=-\n");
|
||||
assert!(m.timezone.is_none());
|
||||
assert!(m.cpu_percent.is_none());
|
||||
assert!(m.disks.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,109 @@
|
||||
use std::fs;
|
||||
//! Host key verification against `~/.ssh/known_hosts` with UI confirmation.
|
||||
//!
|
||||
//! Unknown / changed keys emit `ssh:host-key-prompt` and block until the UI
|
||||
//! responds (or the wait times out). Silent TOFU is intentionally not used.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHostKeyFormat, Session};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PROMPT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HostKeyDecision {
|
||||
AcceptOnce,
|
||||
AcceptAlways,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl HostKeyDecision {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"accept-once" => Some(Self::AcceptOnce),
|
||||
"accept-always" => Some(Self::AcceptAlways),
|
||||
"reject" => Some(Self::Reject),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HostKeyPromptEvent {
|
||||
request_id: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
key_type: String,
|
||||
fingerprint: String,
|
||||
is_changed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
previous_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
struct PendingPrompt {
|
||||
tx: std::sync::mpsc::Sender<HostKeyDecision>,
|
||||
}
|
||||
|
||||
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
static PENDING: OnceLock<Mutex<HashMap<String, PendingPrompt>>> = OnceLock::new();
|
||||
/// Process-lifetime accept-once cache so tunnels/reconnects do not re-prompt.
|
||||
static ONCE_TRUSTED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn pending_map() -> &'static Mutex<HashMap<String, PendingPrompt>> {
|
||||
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn once_trusted() -> &'static Mutex<HashSet<String>> {
|
||||
ONCE_TRUSTED.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn once_key(host: &str, port: u16, fingerprint: &str) -> String {
|
||||
format!("{host}:{port}:{fingerprint}")
|
||||
}
|
||||
|
||||
/// Bind the app handle once at startup so SSH workers can emit prompts.
|
||||
pub fn bind_app_handle(app: AppHandle) {
|
||||
let _ = APP_HANDLE.set(app);
|
||||
}
|
||||
|
||||
pub fn respond(request_id: &str, decision: HostKeyDecision) -> Result<(), String> {
|
||||
let mut pending = pending_map()
|
||||
.lock()
|
||||
.map_err(|_| "主机密钥确认队列异常".to_string())?;
|
||||
let Some(entry) = pending.remove(request_id) else {
|
||||
return Err(format!("主机密钥确认请求不存在或已过期: {request_id}"));
|
||||
};
|
||||
entry
|
||||
.tx
|
||||
.send(decision)
|
||||
.map_err(|_| "主机密钥确认通道已关闭".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<(), String> {
|
||||
let (key, key_type) = session
|
||||
.host_key()
|
||||
.ok_or_else(|| "服务器未提供主机密钥".to_string())?;
|
||||
let fingerprint = sha256_fingerprint(key);
|
||||
let key_type_name = host_key_type_name(key_type);
|
||||
|
||||
if once_trusted()
|
||||
.lock()
|
||||
.map_err(|_| "主机密钥缓存异常".to_string())?
|
||||
.contains(&once_key(host, port, &fingerprint))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Err("无法确定用户主目录,不能校验 known_hosts".to_string());
|
||||
};
|
||||
@@ -20,24 +118,217 @@ pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Resul
|
||||
.read_file(&path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("读取 known_hosts 失败: {error}"))?;
|
||||
}
|
||||
|
||||
match known.check_port(host, port, key) {
|
||||
CheckResult::Match => Ok(()),
|
||||
CheckResult::Mismatch => Err(format!(
|
||||
"主机密钥已变化,已拒绝连接。请确认安全后从 known_hosts 删除 {}:{} 的旧记录",
|
||||
host, port
|
||||
)),
|
||||
CheckResult::Mismatch => {
|
||||
let previous = lookup_stored_fingerprint(host, port);
|
||||
let decision = prompt_user(
|
||||
host,
|
||||
port,
|
||||
&key_type_name,
|
||||
&fingerprint,
|
||||
true,
|
||||
previous.as_deref(),
|
||||
)?;
|
||||
match decision {
|
||||
HostKeyDecision::Reject => {
|
||||
Err(format!("主机密钥已变化,已拒绝连接({host}:{port})"))
|
||||
}
|
||||
HostKeyDecision::AcceptOnce => {
|
||||
remember_once(host, port, &fingerprint);
|
||||
Ok(())
|
||||
}
|
||||
HostKeyDecision::AcceptAlways => {
|
||||
remove_known_host_entries(host, port)?;
|
||||
// Reload after deletion so add does not keep the stale key.
|
||||
let mut known = session
|
||||
.known_hosts()
|
||||
.map_err(|error| format!("初始化 known_hosts 失败: {error}"))?;
|
||||
if path.is_file() {
|
||||
known
|
||||
.read_file(&path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("读取 known_hosts 失败: {error}"))?;
|
||||
}
|
||||
write_known_host(&mut known, &path, host, port, key, key_type)?;
|
||||
remember_once(host, port, &fingerprint);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
CheckResult::NotFound | CheckResult::Failure => {
|
||||
let host_label = known_host_label(host, port);
|
||||
known
|
||||
.add(&host_label, key, &host_label, known_host_format(key_type))
|
||||
.map_err(|error| format!("记录主机密钥失败: {error}"))?;
|
||||
known
|
||||
.write_file(&path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("写入 known_hosts 失败: {error}"))
|
||||
let decision = prompt_user(host, port, &key_type_name, &fingerprint, false, None)?;
|
||||
match decision {
|
||||
HostKeyDecision::Reject => Err(format!("用户拒绝了主机密钥({host}:{port})")),
|
||||
HostKeyDecision::AcceptOnce => {
|
||||
remember_once(host, port, &fingerprint);
|
||||
Ok(())
|
||||
}
|
||||
HostKeyDecision::AcceptAlways => {
|
||||
write_known_host(&mut known, &path, host, port, key, key_type)?;
|
||||
remember_once(host, port, &fingerprint);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_user(
|
||||
host: &str,
|
||||
port: u16,
|
||||
key_type: &str,
|
||||
fingerprint: &str,
|
||||
is_changed: bool,
|
||||
previous_fingerprint: Option<&str>,
|
||||
) -> Result<HostKeyDecision, String> {
|
||||
let Some(app) = APP_HANDLE.get() else {
|
||||
return Err(
|
||||
"无法弹出主机密钥确认(应用未就绪)。请稍后重试,或手动将密钥写入 ~/.ssh/known_hosts"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
pending_map()
|
||||
.lock()
|
||||
.map_err(|_| "主机密钥确认队列异常".to_string())?
|
||||
.insert(request_id.clone(), PendingPrompt { tx });
|
||||
|
||||
let payload = HostKeyPromptEvent {
|
||||
request_id: request_id.clone(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
key_type: key_type.to_string(),
|
||||
fingerprint: fingerprint.to_string(),
|
||||
is_changed,
|
||||
previous_fingerprint: previous_fingerprint.map(str::to_string),
|
||||
};
|
||||
|
||||
if app.emit("ssh:host-key-prompt", payload).is_err() {
|
||||
pending_map()
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut map| map.remove(&request_id));
|
||||
return Err("发送主机密钥确认事件失败".to_string());
|
||||
}
|
||||
|
||||
match rx.recv_timeout(PROMPT_TIMEOUT) {
|
||||
Ok(decision) => Ok(decision),
|
||||
Err(_) => {
|
||||
pending_map()
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut map| map.remove(&request_id));
|
||||
Err(format!(
|
||||
"主机密钥确认超时({} 秒)",
|
||||
PROMPT_TIMEOUT.as_secs()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_once(host: &str, port: u16, fingerprint: &str) {
|
||||
if let Ok(mut set) = once_trusted().lock() {
|
||||
set.insert(once_key(host, port, fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_known_host(
|
||||
known: &mut ssh2::KnownHosts,
|
||||
path: &std::path::Path,
|
||||
host: &str,
|
||||
port: u16,
|
||||
key: &[u8],
|
||||
key_type: HostKeyType,
|
||||
) -> Result<(), String> {
|
||||
let host_label = known_host_label(host, port);
|
||||
known
|
||||
.add(&host_label, key, &host_label, known_host_format(key_type))
|
||||
.map_err(|error| format!("记录主机密钥失败: {error}"))?;
|
||||
known
|
||||
.write_file(path, KnownHostFileKind::OpenSSH)
|
||||
.map_err(|error| format!("写入 known_hosts 失败: {error}"))
|
||||
}
|
||||
|
||||
fn remove_known_host_entries(host: &str, port: u16) -> Result<(), String> {
|
||||
let Some(path) = dirs::home_dir().map(|home| home.join(".ssh").join("known_hosts")) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let input = fs::read_to_string(&path).map_err(|error| error.to_string())?;
|
||||
let label = known_host_label(host, port);
|
||||
let retained = input
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_whitespace().next().map_or(true, |hosts| {
|
||||
!hosts
|
||||
.split(',')
|
||||
.any(|candidate| candidate == label || (port == 22 && candidate == host))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(
|
||||
path,
|
||||
if retained.is_empty() {
|
||||
retained
|
||||
} else {
|
||||
format!("{retained}\n")
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn lookup_stored_fingerprint(host: &str, port: u16) -> Option<String> {
|
||||
let path = dirs::home_dir()?.join(".ssh").join("known_hosts");
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let input = fs::read_to_string(path).ok()?;
|
||||
let label = known_host_label(host, port);
|
||||
for line in input
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with('#'))
|
||||
{
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
if fields.len() < 3 || fields[0].starts_with('|') {
|
||||
continue;
|
||||
}
|
||||
let matches = fields[0]
|
||||
.split(',')
|
||||
.any(|candidate| candidate == label || (port == 22 && candidate == host));
|
||||
if !matches {
|
||||
continue;
|
||||
}
|
||||
let Ok(key) = STANDARD_NO_PAD.decode(fields[2]) else {
|
||||
continue;
|
||||
};
|
||||
return Some(sha256_fingerprint(&key));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sha256_fingerprint(key: &[u8]) -> String {
|
||||
STANDARD_NO_PAD.encode(Sha256::digest(key))
|
||||
}
|
||||
|
||||
fn host_key_type_name(key_type: HostKeyType) -> String {
|
||||
match key_type {
|
||||
HostKeyType::Rsa => "ssh-rsa",
|
||||
HostKeyType::Dss => "ssh-dss",
|
||||
HostKeyType::Ecdsa256 => "ecdsa-sha2-nistp256",
|
||||
HostKeyType::Ecdsa384 => "ecdsa-sha2-nistp384",
|
||||
HostKeyType::Ecdsa521 => "ecdsa-sha2-nistp521",
|
||||
HostKeyType::Ed25519 => "ssh-ed25519",
|
||||
HostKeyType::Unknown => "unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn known_host_label(host: &str, port: u16) -> String {
|
||||
if port == 22 {
|
||||
host.to_string()
|
||||
@@ -67,4 +358,20 @@ mod tests {
|
||||
assert_eq!(known_host_label("example.com", 22), "example.com");
|
||||
assert_eq!(known_host_label("example.com", 2222), "[example.com]:2222");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_host_key_decisions() {
|
||||
assert_eq!(
|
||||
HostKeyDecision::parse("accept-always"),
|
||||
Some(HostKeyDecision::AcceptAlways)
|
||||
);
|
||||
assert_eq!(HostKeyDecision::parse("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_unpadded_sha256_base64() {
|
||||
let fp = sha256_fingerprint(b"test-key");
|
||||
assert!(!fp.contains('='));
|
||||
assert!(!fp.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,36 @@ impl SshService {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_upload_dir(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
sftp_id: &str,
|
||||
local_dir: &str,
|
||||
remote_dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
upload_dir(
|
||||
&sftp,
|
||||
&expand_home(local_dir),
|
||||
remote_dir,
|
||||
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sftp_download_dir(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
sftp_id: &str,
|
||||
remote_dir: &str,
|
||||
local_dir: &str,
|
||||
) -> Result<(), String> {
|
||||
let sftp = self.sftp(sftp_id)?;
|
||||
let local_dir = expand_home(local_dir);
|
||||
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
|
||||
emit_progress(app, sftp_id, transferred, total)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sftp_rename(
|
||||
&self,
|
||||
sftp_id: &str,
|
||||
@@ -311,6 +341,140 @@ pub(super) fn download_file(
|
||||
fs::rename(&partial_path, local_path).map_err(|error| format!("提交下载文件失败: {error}"))
|
||||
}
|
||||
|
||||
fn join_remote(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() || parent == "/" {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("{}/{name}", parent.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir_mode(mode: u32) -> bool {
|
||||
mode & 0o170000 == 0o040000
|
||||
}
|
||||
|
||||
fn ensure_remote_dir(sftp: &ssh2::Sftp, remote_dir: &str) -> Result<(), String> {
|
||||
let normalized = if remote_dir.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
remote_dir.trim_end_matches('/').to_string()
|
||||
};
|
||||
if normalized.is_empty() || normalized == "/" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for part in normalized.trim_start_matches('/').split('/') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
return Err("远程目录路径不能包含 ..".into());
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
let mut current = String::new();
|
||||
for part in parts {
|
||||
current = if current.is_empty() {
|
||||
format!("/{part}")
|
||||
} else {
|
||||
format!("{current}/{part}")
|
||||
};
|
||||
match sftp.lstat(Path::new(¤t)) {
|
||||
Ok(stat) if is_dir_mode(stat.perm.unwrap_or(0)) => continue,
|
||||
Ok(_) => return Err(format!("远程路径已存在且不是目录: {current}")),
|
||||
Err(_) => {
|
||||
sftp.mkdir(Path::new(¤t), 0o755)
|
||||
.map_err(|error| format!("创建远程目录失败 ({current}): {error}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload_dir(
|
||||
sftp: &ssh2::Sftp,
|
||||
local_dir: &Path,
|
||||
remote_dir: &str,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
let meta = fs::metadata(local_dir)
|
||||
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(format!("本地路径不是目录: {}", local_dir.display()));
|
||||
}
|
||||
ensure_remote_dir(sftp, remote_dir)?;
|
||||
|
||||
let mut entries = fs::read_dir(local_dir)
|
||||
.map_err(|error| format!("遍历本地目录失败 ({}): {error}", local_dir.display()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("读取本地目录项失败: {error}"))?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let local_child = entry.path();
|
||||
let remote_child = join_remote(remote_dir, &name);
|
||||
if file_type.is_dir() {
|
||||
upload_dir(sftp, &local_child, &remote_child, progress)?;
|
||||
} else if file_type.is_file() {
|
||||
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
}
|
||||
// skip symlinks / special files for safety
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_dir(
|
||||
sftp: &ssh2::Sftp,
|
||||
remote_dir: &str,
|
||||
local_dir: &Path,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
let stat = sftp
|
||||
.lstat(Path::new(remote_dir))
|
||||
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
|
||||
if !is_dir_mode(stat.perm.unwrap_or(0)) {
|
||||
return Err(format!("远程路径不是目录: {remote_dir}"));
|
||||
}
|
||||
fs::create_dir_all(local_dir)
|
||||
.map_err(|error| format!("创建本地目录失败 ({}): {error}", local_dir.display()))?;
|
||||
|
||||
let mut entries = sftp
|
||||
.readdir(Path::new(remote_dir))
|
||||
.map_err(|error| format!("遍历远程目录失败 ({remote_dir}): {error}"))?;
|
||||
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
|
||||
|
||||
for (path, entry_stat) in entries {
|
||||
let name = match path.file_name() {
|
||||
Some(value) => value.to_string_lossy().to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let remote_child = join_remote(remote_dir, &name);
|
||||
let local_child = local_dir.join(&name);
|
||||
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
|
||||
download_dir(sftp, &remote_child, &local_child, progress)?;
|
||||
} else {
|
||||
download_file(sftp, &remote_child, &local_child, |transferred, total| {
|
||||
progress(transferred, total)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn copy_with_progress(
|
||||
reader: &mut impl Read,
|
||||
writer: &mut impl Write,
|
||||
|
||||
Reference in new issue
Block a user