This commit is contained in:
netadmin committed 2026-10-23 21:34:20 +03:00
1 parent e88dc07696
commit 5d01dfb63a
12 files changed
+2438 -1891

No files matched your search

+511 -202
View File
@@ -1,24 +1,59 @@
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
net::Ipv4Addr,
sync::{Arc, RwLock},
time::{SystemTime, UNIX_EPOCH},
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, Ordering},
Arc,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::sync::{Mutex, Semaphore};
pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024;
// ───────────────────────── Лимиты ─────────────────────────
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 MEDIA_DIR: &str = "received_media";
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;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SavedIdentity {
pub cert_hex: String,
pub key_hex: String,
pub fingerprint: String,
// ───────────────────────── Ошибки ─────────────────────────
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,
@@ -32,8 +67,10 @@ pub struct MediaItem {
pub filename: String,
pub saved_path: String,
pub mime: String,
pub size: usize,
pub size: u64,
pub timestamp: u64,
pub previewable: bool,
pub risky: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -43,18 +80,34 @@ pub struct PeerDevice {
pub ips: Vec<String>,
pub port: u16,
pub fingerprint: String,
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
pub pin_code: String,
pub verified: bool,
pub online: bool,
pub last_seen: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BeaconPacket {
pub name: String,
pub ip: String,
pub port: u16,
pub fingerprint: String,
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,
online: false,
last_seen: 0,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
In,
Out,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -62,10 +115,9 @@ pub struct TransferProgress {
pub id: String,
pub filename: String,
pub peer_name: String,
/// "out" (отправка) или "in" (прием)
pub direction: String,
pub transferred_bytes: usize,
pub total_bytes: usize,
pub direction: Direction,
pub transferred_bytes: u64,
pub total_bytes: u64,
pub percent: u8,
pub done: bool,
pub error: Option<String>,
@@ -81,99 +133,8 @@ pub struct ScanProgress {
pub found: usize,
}
pub struct AppState {
pub device_name: String,
pub my_ip: String,
pub p2p_port: u16,
pub my_fingerprint: String,
pub ping_interval_sec: u64,
pub ram_texts: RwLock<VecDeque<TextItem>>,
pub disk_media: RwLock<VecDeque<MediaItem>>,
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
pub http_client: reqwest::Client,
pub probe_client: reqwest::Client,
pub transfers: RwLock<Vec<TransferProgress>>,
pub scan_status: RwLock<ScanProgress>,
pub logs: RwLock<VecDeque<String>>,
}
impl AppState {
pub fn add_log(&self, msg: impl Into<String>) {
let text = msg.into();
println!("[LOG] {}", text);
let mut logs = self.logs.write().unwrap();
if logs.len() >= MAX_LOGS {
logs.pop_back();
}
logs.push_front(format!("[{}] {}", now_sec(), text));
}
pub fn upsert_transfer(&self, item: TransferProgress) {
let mut list = self.transfers.write().unwrap();
if let Some(existing) = list.iter_mut().find(|t| t.id == item.id) {
*existing = item;
} else {
list.insert(0, item);
}
}
pub fn remove_transfer(&self, id: &str) {
let mut list = self.transfers.write().unwrap();
list.retain(|t| t.id != id);
}
pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) -> bool {
if fingerprint == self.my_fingerprint || fingerprint.len() < 8 {
return false;
}
if ip.is_empty() || ip == "0.0.0.0" || ip == "127.0.0.1" {
return false;
}
let pin_code = fingerprint[..8].to_uppercase();
let now = now_sec();
let mut peers = self.peers.write().unwrap();
if let Some(existing) = peers.get_mut(&fingerprint) {
existing.name = name;
existing.port = port;
existing.online = true;
existing.last_seen = now;
if let Some(pos) = existing.ips.iter().position(|x| x == &ip) {
existing.ips.remove(pos);
}
existing.ips.insert(0, ip);
false
} else {
drop(peers);
self.add_log(format!(
"Обнаружено устройство '{}' ({}:{}) PIN: [{}]",
name, ip, port, pin_code
));
let mut peers = self.peers.write().unwrap();
peers.insert(
fingerprint.clone(),
PeerDevice {
id: fingerprint.clone(),
name,
ips: vec![ip],
port,
fingerprint,
pin_code,
verified: false,
online: true,
last_seen: now,
},
);
true
}
}
}
#[derive(Serialize, Deserialize)]
pub struct IncomingTextPayload {
pub sender: String,
pub sender_fp: String,
pub text: String,
}
@@ -201,6 +162,240 @@ pub struct UiScanRangeRequest {
pub port: Option<u16>,
}
// ───────────────────────── Состояние ─────────────────────────
pub struct AppState {
pub device_name: String,
pub my_ip: String,
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, Instant>>,
}
impl AppState {
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();
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,
})
.collect();
if let Err(e) = storage::save_trusted(&self.data_dir, &entries) {
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.clone();
p.port = port;
p.online = true;
p.last_seen = 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.last_seen = 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
}
/// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен.
pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool {
let ip_s = ip.to_string();
self.peers.read().get(fp).map_or(false, |p| {
p.online
&& 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) -> 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(30));
}
match m.get(&ip) {
Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false,
_ => {
m.insert(ip, now);
true
}
}
}
pub fn maintenance(&self) {
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)
@@ -208,132 +403,246 @@ pub fn now_sec() -> u64 {
.as_secs()
}
pub fn url_decode(input: &str) -> String {
let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.as_bytes().iter().copied();
while let Some(b) = chars.next() {
if b == b'%' {
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
if let Ok(decoded) =
u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16)
{
bytes.push(decoded);
continue;
}
}
} else if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
pub fn percent(done: u64, total: u64) -> u8 {
if total == 0 {
100
} else {
((done as u128 * 100) / total as u128).min(100) as u8
}
String::from_utf8_lossy(&bytes).into_owned()
}
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()
}
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())
}
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
ip.is_private() || ip.is_link_local()
}
/// Принимает уже раскодированное имя. Ничего не декодирует.
pub fn sanitize_filename(name: &str) -> String {
let decoded = url_decode(name);
let clean: String = decoded
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let trimmed = clean.trim_matches('.').trim();
let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace());
if trimmed.is_empty() {
"media_file.bin".to_string()
} else {
trimmed.to_string()
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(),
}
}
/// Парсер диапазонов IP-адресов:
/// Поддерживает: "all", "192.168.1.1-254", "192.168.1.10-50", "192.168.1.10-192.168.1.80", "192.168.1.0/24"
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<String>), String> {
/// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32),
/// одиночные адреса и списки через запятую. Только локальные (private/link-local) 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().ok();
let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") {
let base: Ipv4Addr = my_ip
.parse()
.map_err(|_| "Не удалось определить подсеть автоматически. Введите диапазон вручную.".to_string())?;
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 result = Vec::new();
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());
}
if let Some(cidr_base) = token.strip_suffix("/24") {
let ip: Ipv4Addr = cidr_base
.trim()
.parse()
.map_err(|_| format!("Некорректный CIDR адрес: {}", token))?;
let o = ip.octets();
for last in 1..=254u8 {
result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last));
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 = left.trim();
let right = right.trim();
let start_ip: Ipv4Addr = left
.parse()
.map_err(|_| format!("Некорректный начальный IP: {}", left))?;
if right.contains('.') {
let end_ip: Ipv4Addr = right
.parse()
.map_err(|_| format!("Некорректный конечный IP: {}", right))?;
let start_u = u32::from(start_ip);
let end_u = u32::from(end_ip);
if end_u < start_u {
return Err("Конечный IP меньше начального".into());
}
if end_u - start_u > 1024 {
return Err("Диапазон слишком велик (максимум 1024 адреса за раз)".into());
}
for val in start_u..=end_u {
let ip = Ipv4Addr::from(val);
let last = ip.octets()[3];
if last != 0 && last != 255 {
result.push(ip.to_string());
}
}
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 end_last: u8 = right
.parse()
.map_err(|_| format!("Некорректный конец диапазона: {}", right))?;
let o = start_ip.octets();
let start_last = o[3];
if end_last < start_last {
return Err(format!("Конец диапазона ({}) меньше начала ({})", end_last, start_last));
}
for last in start_last..=end_last {
if last != 0 && last != 255 {
result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last));
}
}
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))?;
result.push(ip.to_string());
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));
}
}
}
result.retain(|ip| ip != my_ip && ip != "127.0.0.1" && ip != "0.0.0.0");
result.dedup();
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);
}
}