848 lines
30 KiB
Rust
848 lines
30 KiB
Rust
use crate::storage::{self, TrustedEntry};
|
||
use axum::{
|
||
http::{HeaderMap, StatusCode},
|
||
response::{IntoResponse, Response},
|
||
};
|
||
use parking_lot::{Mutex as PlMutex, RwLock};
|
||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||
use serde::{Deserialize, Serialize};
|
||
use sha2::{Digest, Sha256};
|
||
use std::{
|
||
collections::{hash_map::Entry, BTreeSet, HashMap, VecDeque},
|
||
fmt::Display,
|
||
net::{IpAddr, Ipv4Addr},
|
||
path::PathBuf,
|
||
sync::{
|
||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||
Arc,
|
||
},
|
||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||
};
|
||
use tokio::sync::{Mutex, Notify, Semaphore};
|
||
|
||
// ───────────────────────── Лимиты ─────────────────────────
|
||
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл
|
||
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // всего в received_media
|
||
pub const MAX_TEXT_BYTES: usize = 64 * 1024;
|
||
pub const MAX_RAM_TEXTS: usize = 50;
|
||
pub const MAX_LOGS: usize = 100;
|
||
pub const MAX_PEERS: usize = 256;
|
||
pub const MAX_PEER_IPS: usize = 4;
|
||
pub const MAX_TRANSFERS: usize = 20;
|
||
pub const MAX_INCOMING: usize = 4;
|
||
pub const MAX_MEDIA_LIST: usize = 500;
|
||
pub const DISCOVERY_PORT: u16 = 5354;
|
||
pub const MIN_PING_SEC: u64 = 10;
|
||
pub const MAX_PING_SEC: u64 = 30;
|
||
pub const DEFAULT_PING_SEC: u64 = 15;
|
||
pub const MAX_PING_FAILS: u8 = 5;
|
||
|
||
// ───────────────────────── Ошибки ─────────────────────────
|
||
pub struct AppError(pub StatusCode, pub String);
|
||
|
||
impl AppError {
|
||
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
|
||
Self(status, msg.into())
|
||
}
|
||
/// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей).
|
||
pub fn internal(ctx: &str, e: impl Display) -> Self {
|
||
eprintln!("[ERR] {}: {}", ctx, e);
|
||
Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into())
|
||
}
|
||
}
|
||
|
||
impl IntoResponse for AppError {
|
||
fn into_response(self) -> Response {
|
||
(self.0, self.1).into_response()
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── DTO ─────────────────────────
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct TextItem {
|
||
pub sender: String,
|
||
pub text: String,
|
||
pub timestamp: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct MediaItem {
|
||
pub sender: String,
|
||
pub filename: String,
|
||
pub saved_path: String,
|
||
pub mime: String,
|
||
pub size: u64,
|
||
pub timestamp: u64,
|
||
pub previewable: bool,
|
||
pub risky: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct PeerDevice {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub ips: Vec<String>,
|
||
pub port: u16,
|
||
pub fingerprint: String,
|
||
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
||
pub pin_code: String,
|
||
/// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем).
|
||
pub verified: bool,
|
||
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||
pub trusts_me: bool,
|
||
pub online: bool,
|
||
/// Когда устройство последний раз отвечало по сети (unix timestamp, сек).
|
||
pub last_seen: u64,
|
||
/// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек).
|
||
pub last_ping: u64,
|
||
/// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается).
|
||
pub fail_count: u8,
|
||
}
|
||
|
||
impl PeerDevice {
|
||
pub fn new(fp: &str, name: String, ips: Vec<String>, port: u16, my_fp: &str, verified: bool) -> Self {
|
||
Self {
|
||
id: fp.to_string(),
|
||
name,
|
||
ips,
|
||
port,
|
||
fingerprint: fp.to_string(),
|
||
pin_code: sas_code(my_fp, fp),
|
||
verified,
|
||
trusts_me: false,
|
||
online: false,
|
||
last_seen: 0,
|
||
last_ping: 0,
|
||
fail_count: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum Direction {
|
||
In,
|
||
Out,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct TransferProgress {
|
||
pub id: String,
|
||
pub filename: String,
|
||
pub peer_name: String,
|
||
pub direction: Direction,
|
||
pub transferred_bytes: u64,
|
||
pub total_bytes: u64,
|
||
pub percent: u8,
|
||
pub done: bool,
|
||
pub error: Option<String>,
|
||
pub updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct ScanProgress {
|
||
pub active: bool,
|
||
pub range: String,
|
||
pub scanned: usize,
|
||
pub total: usize,
|
||
pub found: usize,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
pub struct IncomingTextPayload {
|
||
pub text: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiSendTextRequest {
|
||
pub peer_id: String,
|
||
pub text: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiTrustRequest {
|
||
pub peer_id: String,
|
||
pub trust: bool,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiPingPeerRequest {
|
||
pub peer_id: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiManualPeerRequest {
|
||
pub ip: String,
|
||
pub port: Option<u16>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiScanRangeRequest {
|
||
pub range: String,
|
||
pub port: Option<u16>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UiSettingsRequest {
|
||
pub ping_interval_sec: u64,
|
||
}
|
||
|
||
// ───────────────────────── Состояние ─────────────────────────
|
||
pub struct AppState {
|
||
pub device_name: String,
|
||
pub my_ip: RwLock<String>,
|
||
pub fixed_ip: bool,
|
||
pub p2p_port: u16,
|
||
pub my_fingerprint: String,
|
||
pub cert_der: Vec<u8>,
|
||
pub key_der: Vec<u8>,
|
||
pub data_dir: PathBuf,
|
||
pub media_dir: PathBuf,
|
||
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||
pub probe_client: reqwest::Client,
|
||
pub transfers: RwLock<Vec<TransferProgress>>,
|
||
pub scan_status: RwLock<ScanProgress>,
|
||
pub logs: RwLock<VecDeque<String>>,
|
||
pub incoming_slots: Arc<Semaphore>,
|
||
pub ping_lock: Mutex<()>,
|
||
pub trust_dirty: AtomicBool,
|
||
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||
/// Интервал опроса известных пиров (сек), меняется на лету
|
||
pub ping_interval_sec: AtomicU64,
|
||
/// Время последнего глобального опроса устройств (unix timestamp, сек)
|
||
pub last_ping: AtomicU64,
|
||
pub ping_notify: Notify,
|
||
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
||
pub log_stdout: bool,
|
||
/// true в `serve --no-ui`: входящий текст печатается в терминал
|
||
pub headless: bool,
|
||
/// mtime trusted.json, чтобы подхватывать правки из CLI (trust/untrust) работающим узлом
|
||
pub trust_mtime: PlMutex<Option<SystemTime>>,
|
||
}
|
||
|
||
impl AppState {
|
||
pub fn current_ip(&self) -> String {
|
||
self.my_ip.read().clone()
|
||
}
|
||
|
||
pub fn update_my_ip(&self, ip: Ipv4Addr) -> bool {
|
||
if self.fixed_ip {
|
||
return false;
|
||
}
|
||
let s = ip.to_string();
|
||
let mut cur = self.my_ip.write();
|
||
if *cur != s {
|
||
*cur = s;
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
pub fn add_log(&self, msg: impl Into<String>) {
|
||
// защита от log-injection: управляющие символы в именах и т.п.
|
||
let text: String = msg
|
||
.into()
|
||
.chars()
|
||
.map(|c| if c.is_control() { ' ' } else { c })
|
||
.collect();
|
||
if self.log_stdout {
|
||
println!("[LOG] {}", text);
|
||
}
|
||
let mut logs = self.logs.write();
|
||
if logs.len() >= MAX_LOGS {
|
||
logs.pop_back();
|
||
}
|
||
logs.push_front(format!("[{}] {}", now_sec(), text));
|
||
}
|
||
|
||
// ── передачи ──
|
||
pub fn add_transfer(&self, t: TransferProgress) {
|
||
let mut l = self.transfers.write();
|
||
l.insert(0, t);
|
||
l.truncate(MAX_TRANSFERS);
|
||
}
|
||
|
||
pub fn update_transfer_bytes(&self, id: &str, bytes: u64) {
|
||
let mut l = self.transfers.write();
|
||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||
t.transferred_bytes = bytes;
|
||
t.percent = percent(bytes, t.total_bytes);
|
||
t.updated_at = now_sec();
|
||
}
|
||
}
|
||
|
||
pub fn finish_transfer(&self, id: &str) {
|
||
let mut l = self.transfers.write();
|
||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||
t.transferred_bytes = t.total_bytes;
|
||
t.percent = 100;
|
||
t.done = true;
|
||
t.updated_at = now_sec();
|
||
}
|
||
}
|
||
|
||
pub fn fail_transfer(&self, id: &str, msg: String) {
|
||
let mut l = self.transfers.write();
|
||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||
t.done = true;
|
||
t.error = Some(msg);
|
||
t.updated_at = now_sec();
|
||
}
|
||
}
|
||
|
||
pub fn remove_transfer(&self, id: &str) {
|
||
self.transfers.write().retain(|t| t.id != id);
|
||
}
|
||
|
||
// ── доверие ──
|
||
pub fn trusted_name(&self, fp: &str) -> Option<String> {
|
||
self.peers
|
||
.read()
|
||
.get(fp)
|
||
.filter(|p| p.verified)
|
||
.map(|p| p.name.clone())
|
||
}
|
||
|
||
pub fn persist_trust(&self) {
|
||
let entries: Vec<TrustedEntry> = self
|
||
.peers
|
||
.read()
|
||
.values()
|
||
.filter(|p| p.verified)
|
||
.map(|p| TrustedEntry {
|
||
fp: p.id.clone(),
|
||
name: p.name.clone(),
|
||
ips: p.ips.clone(),
|
||
port: p.port,
|
||
trusts_me: p.trusts_me,
|
||
last_seen: p.last_seen,
|
||
})
|
||
.collect();
|
||
match storage::save_trusted(&self.data_dir, &entries) {
|
||
Ok(()) => {
|
||
if let Ok(mt) = std::fs::metadata(self.data_dir.join("trusted.json")).and_then(|m| m.modified()) {
|
||
*self.trust_mtime.lock() = Some(mt);
|
||
}
|
||
}
|
||
Err(e) => self.add_log(format!("Не удалось сохранить список доверенных: {}", e)),
|
||
}
|
||
}
|
||
|
||
pub fn client_for_peer(&self, fp: &str, streaming: bool) -> Result<reqwest::Client, AppError> {
|
||
crate::tls::pinned_client(
|
||
fp.to_string(),
|
||
self.peers.clone(),
|
||
&self.cert_der,
|
||
&self.key_der,
|
||
streaming,
|
||
)
|
||
.map_err(|e| AppError::internal("tls client", e))
|
||
}
|
||
|
||
// ── пиры ──
|
||
/// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия.
|
||
pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool {
|
||
if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 {
|
||
return false;
|
||
}
|
||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||
return false;
|
||
}
|
||
let mut name = clean_label(name, 48);
|
||
if name.is_empty() {
|
||
name = "Unknown".into();
|
||
}
|
||
let ip_s = ip.to_string();
|
||
let now = now_sec();
|
||
let mut created = false;
|
||
let mut dirty = false;
|
||
|
||
{
|
||
let mut peers = self.peers.write();
|
||
if !peers.contains_key(fp) && peers.len() >= MAX_PEERS {
|
||
let victim = peers
|
||
.values()
|
||
.filter(|p| !p.verified && !p.online)
|
||
.min_by_key(|p| p.last_seen)
|
||
.map(|p| p.id.clone());
|
||
match victim {
|
||
Some(v) => {
|
||
peers.remove(&v);
|
||
}
|
||
None => return false,
|
||
}
|
||
}
|
||
match peers.entry(fp.to_string()) {
|
||
Entry::Occupied(mut e) => {
|
||
let p = e.get_mut();
|
||
let changed = p.ips.first() != Some(&ip_s) || p.port != port || p.name != name;
|
||
p.name = name.clone();
|
||
p.port = port;
|
||
p.online = true;
|
||
p.fail_count = 0;
|
||
p.last_seen = now;
|
||
p.last_ping = now;
|
||
p.ips.retain(|x| x != &ip_s);
|
||
p.ips.insert(0, ip_s.clone());
|
||
p.ips.truncate(MAX_PEER_IPS);
|
||
if p.verified && changed {
|
||
dirty = true;
|
||
}
|
||
}
|
||
Entry::Vacant(v) => {
|
||
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
|
||
p.online = true;
|
||
p.fail_count = 0;
|
||
p.last_seen = now;
|
||
p.last_ping = now;
|
||
v.insert(p);
|
||
created = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if dirty {
|
||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||
}
|
||
if created {
|
||
self.add_log(format!(
|
||
"Обнаружено устройство '{}' ({}:{}), код сверки: {}",
|
||
name,
|
||
ip,
|
||
port,
|
||
sas_code(&self.my_fingerprint, fp)
|
||
));
|
||
}
|
||
created
|
||
}
|
||
|
||
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
|
||
pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) {
|
||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||
return;
|
||
}
|
||
let ip_s = ip.to_string();
|
||
let now = now_sec();
|
||
let mut dirty = false;
|
||
if let Some(p) = self.peers.write().get_mut(fp) {
|
||
let changed = p.ips.first() != Some(&ip_s);
|
||
p.online = true;
|
||
p.fail_count = 0;
|
||
p.last_seen = now;
|
||
if changed {
|
||
p.ips.retain(|x| x != &ip_s);
|
||
p.ips.insert(0, ip_s);
|
||
p.ips.truncate(MAX_PEER_IPS);
|
||
if p.verified {
|
||
dirty = true;
|
||
}
|
||
}
|
||
}
|
||
if dirty {
|
||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
/// Сбрасывает счётчик неудачных пингов у всех известных устройств (вызывается при ручном опросе или сканировании).
|
||
pub fn reset_all_ping_failures(&self) {
|
||
let mut peers = self.peers.write();
|
||
for p in peers.values_mut() {
|
||
p.fail_count = 0;
|
||
}
|
||
}
|
||
|
||
/// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен.
|
||
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
|
||
let ip_s = ip.to_string();
|
||
self.peers.read().get(fp).map_or(false, |p| {
|
||
p.online
|
||
&& p.port == port
|
||
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
|
||
&& now_sec().saturating_sub(p.last_seen) < 10
|
||
})
|
||
}
|
||
|
||
pub fn probe_allowed(&self, ip: IpAddr, port: u16) -> bool {
|
||
let mut m = self.probe_cooldown.lock();
|
||
let now = Instant::now();
|
||
if m.len() > 512 {
|
||
m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(15));
|
||
}
|
||
match m.get(&(ip, port)) {
|
||
Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false,
|
||
_ => {
|
||
m.insert((ip, port), now);
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Меняет интервал опроса: применяется сразу и сохраняется в config.json.
|
||
pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> {
|
||
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) {
|
||
return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC));
|
||
}
|
||
self.ping_interval_sec.store(sec, Ordering::Relaxed);
|
||
self.ping_notify.notify_one(); // будим цикл опроса, чтобы он взял новое значение
|
||
if let Err(e) = storage::update_config(&self.data_dir, |c| c.ping_interval_sec = Some(sec)) {
|
||
self.add_log(format!("Не удалось сохранить config.json: {}", e));
|
||
}
|
||
self.add_log(format!("Интервал опроса устройств: {} с", sec));
|
||
Ok(())
|
||
}
|
||
|
||
/// Бит «собеседник подтвердил сверку с нами». Источник — только аутентифицированный (mTLS)
|
||
/// канал: заголовок/ответ /p2p/info от владельца отпечатка.
|
||
pub fn set_remote_trust(&self, fp: &str, v: bool) {
|
||
let (msg, hint) = {
|
||
let mut peers = self.peers.write();
|
||
let Some(p) = peers.get_mut(fp) else { return };
|
||
if p.trusts_me == v {
|
||
return;
|
||
}
|
||
p.trusts_me = v;
|
||
if v && !p.verified {
|
||
(
|
||
format!(
|
||
"«{}» запрашивает взаимную сверку. Код сверки: {} — сравните его на экране устройства и подтвердите",
|
||
p.name, p.pin_code
|
||
),
|
||
Some(short_id(fp)),
|
||
)
|
||
} else if v {
|
||
(format!("«{}» подтвердил сверку — доверие взаимное", p.name), None)
|
||
} else {
|
||
(format!("«{}» отозвал доверие к вашему устройству", p.name), None)
|
||
}
|
||
};
|
||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||
self.add_log(msg);
|
||
if let (true, Some(id)) = (self.headless, hint) {
|
||
println!(" → подтвердить: ramshare_ui trust {}", id);
|
||
}
|
||
}
|
||
|
||
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
||
pub fn sync_trust_from_disk(&self) {
|
||
let path = self.data_dir.join("trusted.json");
|
||
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
||
{
|
||
let mut last = self.trust_mtime.lock();
|
||
if *last == Some(mt) {
|
||
return;
|
||
}
|
||
*last = Some(mt);
|
||
}
|
||
let Some(entries) = storage::read_trusted_strict(&self.data_dir) else { return };
|
||
let mut peers = self.peers.write();
|
||
for p in peers.values_mut() {
|
||
p.verified = entries.iter().any(|e| e.fp == p.id);
|
||
}
|
||
for e in entries {
|
||
if !is_valid_fp(&e.fp) || e.fp == self.my_fingerprint || peers.contains_key(&e.fp) {
|
||
continue;
|
||
}
|
||
if peers.len() >= MAX_PEERS {
|
||
break;
|
||
}
|
||
let ips: Vec<String> = e
|
||
.ips
|
||
.into_iter()
|
||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||
.take(MAX_PEER_IPS)
|
||
.collect();
|
||
let port = if e.port == 0 { 8443 } else { e.port };
|
||
let mut np = PeerDevice::new(&e.fp, clean_label(&e.name, 48), ips, port, &self.my_fingerprint, true);
|
||
np.trusts_me = e.trusts_me;
|
||
np.last_seen = e.last_seen;
|
||
peers.insert(e.fp.clone(), np);
|
||
}
|
||
}
|
||
|
||
pub fn maintenance(&self) {
|
||
self.sync_trust_from_disk();
|
||
let now = now_sec();
|
||
self.peers
|
||
.write()
|
||
.retain(|_, p| p.verified || p.online || now.saturating_sub(p.last_seen) < 600);
|
||
if self.trust_dirty.swap(false, Ordering::Relaxed) {
|
||
self.persist_trust();
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn remove_transfer_later(state: &Arc<AppState>, id: String, secs: u64) {
|
||
let st = state.clone();
|
||
tokio::spawn(async move {
|
||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||
st.remove_transfer(&id);
|
||
});
|
||
}
|
||
|
||
// ───────────────────────── Утилиты ─────────────────────────
|
||
pub fn now_sec() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs()
|
||
}
|
||
|
||
pub fn percent(done: u64, total: u64) -> u8 {
|
||
if total == 0 {
|
||
100
|
||
} else {
|
||
((done as u128 * 100) / total as u128).min(100) as u8
|
||
}
|
||
}
|
||
|
||
pub fn fingerprint(der: &[u8]) -> String {
|
||
hex::encode(Sha256::digest(der))
|
||
}
|
||
|
||
pub fn is_valid_fp(fp: &str) -> bool {
|
||
fp.len() == 64 && fp.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||
}
|
||
|
||
pub fn short_id(fp: &str) -> String {
|
||
fp.get(..8).unwrap_or(fp).to_uppercase()
|
||
}
|
||
|
||
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
||
pub fn sas_code(a: &str, b: &str) -> String {
|
||
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
||
let mut h = Sha256::new();
|
||
h.update(b"ramshare-sas-v1");
|
||
h.update(x.as_bytes());
|
||
h.update(b"|");
|
||
h.update(y.as_bytes());
|
||
let digest = h.finalize();
|
||
let s = hex::encode_upper(&digest[..10]);
|
||
s.as_bytes()
|
||
.chunks(4)
|
||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||
.collect::<Vec<_>>()
|
||
.join("-")
|
||
}
|
||
|
||
pub fn random_hex(n: usize) -> String {
|
||
let mut b = vec![0u8; n];
|
||
getrandom::getrandom(&mut b).expect("ОС не предоставила случайные байты");
|
||
hex::encode(b)
|
||
}
|
||
|
||
/// Имя устройства и т.п.: без управляющих и bidi-символов, ограниченной длины.
|
||
pub fn clean_label(s: &str, max_chars: usize) -> String {
|
||
s.chars()
|
||
.filter(|c| {
|
||
!c.is_control() && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}')
|
||
})
|
||
.collect::<String>()
|
||
.trim()
|
||
.chars()
|
||
.take(max_chars)
|
||
.collect()
|
||
}
|
||
|
||
/// Для печати чужого текста в терминал: убирает управляющие/escape-символы (кроме \n и \t).
|
||
pub fn strip_ctrl(s: &str) -> String {
|
||
s.chars()
|
||
.map(|c| if c.is_control() && c != '\n' && c != '\t' { '\u{fffd}' } else { c })
|
||
.collect()
|
||
}
|
||
|
||
pub fn pct_decode(s: &str) -> String {
|
||
percent_decode_str(s).decode_utf8_lossy().into_owned()
|
||
}
|
||
|
||
pub fn pct_encode(s: &str) -> String {
|
||
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
|
||
}
|
||
|
||
pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||
h.get(name).and_then(|v| v.to_str().ok())
|
||
}
|
||
|
||
/// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10).
|
||
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
|
||
let o = ip.octets();
|
||
ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000)
|
||
}
|
||
|
||
/// Принимает уже раскодированное имя. Ничего не декодирует.
|
||
pub fn sanitize_filename(name: &str) -> String {
|
||
let clean: String = name
|
||
.chars()
|
||
.map(|c| match c {
|
||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||
c if c.is_control() => '_',
|
||
c => c,
|
||
})
|
||
.collect();
|
||
let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||
if trimmed.is_empty() {
|
||
return "file.bin".into();
|
||
}
|
||
const MAX: usize = 120;
|
||
if trimmed.chars().count() <= MAX {
|
||
return trimmed.to_string();
|
||
}
|
||
match trimmed.rfind('.') {
|
||
Some(i) if trimmed[i..].chars().count() <= 16 => {
|
||
let ext = &trimmed[i..];
|
||
let keep = MAX - ext.chars().count();
|
||
format!("{}{}", trimmed[..i].chars().take(keep).collect::<String>(), ext)
|
||
}
|
||
_ => trimmed.chars().take(MAX).collect(),
|
||
}
|
||
}
|
||
|
||
/// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32),
|
||
/// одиночные адреса и списки через запятую. Только локальные IPv4.
|
||
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<Ipv4Addr>), String> {
|
||
const MAX_TOTAL: usize = 1024;
|
||
let raw = input.trim();
|
||
let mine: Option<Ipv4Addr> = my_ip
|
||
.parse::<Ipv4Addr>()
|
||
.ok()
|
||
.filter(|ip| is_lan_v4(*ip))
|
||
.or_else(crate::net::detect_lan_ip);
|
||
|
||
let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") {
|
||
let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
|
||
let o = base.octets();
|
||
format!("{}.{}.{}.1-254", o[0], o[1], o[2])
|
||
} else {
|
||
raw.to_string()
|
||
};
|
||
if effective.len() > 256 {
|
||
return Err("Слишком длинная строка диапазона".into());
|
||
}
|
||
|
||
let mut set: BTreeSet<u32> = BTreeSet::new();
|
||
let mut tokens = 0;
|
||
|
||
for part in effective.split(',') {
|
||
let token = part.trim();
|
||
if token.is_empty() {
|
||
continue;
|
||
}
|
||
tokens += 1;
|
||
if tokens > 16 {
|
||
return Err("Слишком много элементов в списке (максимум 16)".into());
|
||
}
|
||
|
||
let (start, end): (u32, u32) = if let Some((base, pfx)) = token.split_once('/') {
|
||
let ip: Ipv4Addr = base.trim().parse().map_err(|_| format!("Некорректный CIDR адрес: {}", token))?;
|
||
let p: u32 = pfx.trim().parse().map_err(|_| format!("Некорректная маска: {}", token))?;
|
||
if !(22..=32).contains(&p) {
|
||
return Err("Поддерживаются маски от /22 до /32".into());
|
||
}
|
||
let mask: u32 = !0u32 << (32 - p);
|
||
let s = u32::from(ip) & mask;
|
||
(s, s | !mask)
|
||
} else if let Some((left, right)) = token.split_once('-') {
|
||
let (left, right) = (left.trim(), right.trim());
|
||
let s_ip: Ipv4Addr = left.parse().map_err(|_| format!("Некорректный начальный IP: {}", left))?;
|
||
let s = u32::from(s_ip);
|
||
let e = if right.contains('.') {
|
||
let e_ip: Ipv4Addr = right.parse().map_err(|_| format!("Некорректный конечный IP: {}", right))?;
|
||
u32::from(e_ip)
|
||
} else {
|
||
let last: u8 = right.parse().map_err(|_| format!("Некорректный конец диапазона: {}", right))?;
|
||
(s & !0xFF) | last as u32
|
||
};
|
||
if e < s {
|
||
return Err("Конец диапазона меньше начала".into());
|
||
}
|
||
(s, e)
|
||
} else {
|
||
let ip: Ipv4Addr = token.parse().map_err(|_| format!("Некорректный IP-адрес: {}", token))?;
|
||
(u32::from(ip), u32::from(ip))
|
||
};
|
||
|
||
if end - start > MAX_TOTAL as u32 {
|
||
return Err(format!("Диапазон слишком велик (максимум {} адресов)", MAX_TOTAL));
|
||
}
|
||
for v in start..=end {
|
||
let ip = Ipv4Addr::from(v);
|
||
let last = ip.octets()[3];
|
||
if start != end && (last == 0 || last == 255) {
|
||
continue;
|
||
}
|
||
if !is_lan_v4(ip) {
|
||
return Err(format!("Сканирование разрешено только в локальных сетях: {}", ip));
|
||
}
|
||
set.insert(v);
|
||
if set.len() > MAX_TOTAL {
|
||
return Err(format!("Слишком много адресов (максимум {})", MAX_TOTAL));
|
||
}
|
||
}
|
||
}
|
||
|
||
let result: Vec<Ipv4Addr> = set
|
||
.into_iter()
|
||
.map(Ipv4Addr::from)
|
||
.filter(|ip| Some(*ip) != mine && !ip.is_loopback() && !ip.is_unspecified())
|
||
.collect();
|
||
|
||
if result.is_empty() {
|
||
return Err("Диапазон не содержит доступных для сканирования IP-адресов".into());
|
||
}
|
||
Ok((effective, result))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn range_short_form() {
|
||
let (_, v) = parse_ip_range("192.168.1.10-12", "192.168.1.11").unwrap();
|
||
assert_eq!(v.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn range_rejects_public() {
|
||
assert!(parse_ip_range("8.8.8.8", "192.168.1.2").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn range_all_and_dedup() {
|
||
let (_, v) = parse_ip_range("all", "10.0.0.5").unwrap();
|
||
assert_eq!(v.len(), 253);
|
||
let (_, v) = parse_ip_range("10.0.0.1,10.0.0.1,10.0.0.2", "10.0.0.5").unwrap();
|
||
assert_eq!(v.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn range_too_big() {
|
||
assert!(parse_ip_range("10.0.0.0/16", "10.0.0.5").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn filenames() {
|
||
assert_eq!(sanitize_filename("../../etc/passwd"), "_.._etc_passwd");
|
||
assert_eq!(sanitize_filename("a+b%25.txt"), "a+b%25.txt");
|
||
assert_eq!(sanitize_filename("..."), "file.bin");
|
||
assert!(sanitize_filename(&"x".repeat(500)).chars().count() <= 120);
|
||
}
|
||
|
||
#[test]
|
||
fn fp_validation() {
|
||
assert!(is_valid_fp(&"a".repeat(64)));
|
||
assert!(!is_valid_fp("x');alert(1)//"));
|
||
assert!(!is_valid_fp(&"A".repeat(64)));
|
||
}
|
||
|
||
#[test]
|
||
fn sas_symmetric() {
|
||
let a = "a".repeat(64);
|
||
let b = "b".repeat(64);
|
||
assert_eq!(sas_code(&a, &b), sas_code(&b, &a));
|
||
assert_eq!(sas_code(&a, &b).len(), 24);
|
||
}
|
||
}
|