use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
pub const PORT: u16 = 53317;
pub const MULTICAST_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 167);
pub const API: &str = "/api/localsend/v2";
pub const VERSION: &str = "2.0";
fn default_version() -> String {
VERSION.to_string()
}
fn default_port() -> u16 {
PORT
}
fn default_protocol() -> String {
"https".to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
pub alias: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub device_model: Option<String>,
#[serde(default)]
pub device_type: Option<String>,
#[serde(default)]
pub fingerprint: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_protocol")]
pub protocol: String,
#[serde(default)]
pub download: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub announce: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileMeta {
pub id: String,
pub file_name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub file_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrepareUploadRequest {
pub info: DeviceInfo,
pub files: BTreeMap<String, FileMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pulse: Option<PulseExtra>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PulseExtra {
#[serde(default)]
pub clipboard: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUploadResponse {
pub session_id: String,
pub files: BTreeMap<String, String>,
}
pub fn is_message(files: &BTreeMap<String, FileMeta>) -> bool {
files.len() == 1
&& files.values().all(|f| {
f.file_type == "text/plain" && f.preview.as_ref().is_some_and(|p| !p.is_empty())
})
}
pub fn sanitize_component(raw: &str) -> String {
let replaced: String = raw
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let mut trimmed = replaced
.trim_matches(|c: char| c == '.' || c.is_whitespace())
.to_string();
if trimmed.is_empty() {
return "file".to_string();
}
if cfg!(windows) && is_windows_device(&trimmed) {
trimmed.insert(0, '_');
}
if trimmed.len() <= 200 {
return trimmed;
}
let (stem, ext) = split_extension(&trimmed);
let ext = if ext.len() <= 16 { ext } else { "" };
let mut end = 200usize.saturating_sub(ext.len()).min(stem.len());
while end > 0 && !stem.is_char_boundary(end) {
end -= 1;
}
let cut = stem[..end].trim_end_matches(|c: char| c == '.' || c.is_whitespace());
if cut.is_empty() {
"file".to_string()
} else {
format!("{cut}{ext}")
}
}
fn is_windows_device(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or("").trim_end();
let upper = stem.to_ascii_uppercase();
matches!(
upper.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) || ((upper.starts_with("COM") || upper.starts_with("LPT"))
&& upper.len() == 4
&& upper.as_bytes()[3].is_ascii_digit()
&& upper.as_bytes()[3] != b'0')
}
pub fn sanitize_relative(raw: &str) -> Vec<String> {
let mut parts: Vec<String> = raw
.split(['/', '\\'])
.filter(|p| !p.is_empty() && *p != "." && *p != "..")
.map(sanitize_component)
.collect();
if parts.len() > 8 {
parts = parts.split_off(parts.len() - 8);
}
if parts.is_empty() {
parts.push("file".to_string());
}
parts
}
fn split_extension(name: &str) -> (&str, &str) {
match name.rfind('.') {
Some(dot) if dot > 0 => (&name[..dot], &name[dot..]),
_ => (name, ""),
}
}
pub fn reserve_unique(base: &Path, parts: &[String]) -> io::Result<PathBuf> {
let (dirs, last) = parts
.split_last()
.map(|(last, dirs)| (dirs, last))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty file name"))?;
let mut dir = base.to_path_buf();
for part in dirs {
dir.push(sanitize_component(part));
}
std::fs::create_dir_all(&dir)?;
let last = sanitize_component(last);
let (stem, ext) = split_extension(&last);
for n in 0..10_000u32 {
let candidate = if n == 0 {
last.clone()
} else {
format!("{stem} ({n}){ext}")
};
let path = dir.join(candidate);
if !path.starts_with(base) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path escapes the save folder",
));
}
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"too many files with that name",
))
}
pub fn mime_for(name: &str) -> &'static str {
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"heic" => "image/heic",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"m4a" => "audio/mp4",
"wav" => "audio/wav",
"pdf" => "application/pdf",
"zip" => "application/zip",
"json" => "application/json",
"txt" | "md" | "log" => "text/plain",
"html" | "htm" => "text/html",
"csv" => "text/csv",
_ => "application/octet-stream",
}
}
pub fn random_hex(n: usize) -> String {
let mut bytes = vec![0u8; n];
if !fill_random(&mut bytes) {
use std::hash::{BuildHasher, Hasher};
for chunk in bytes.chunks_mut(8) {
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
);
let value = hasher.finish().to_le_bytes();
chunk.copy_from_slice(&value[..chunk.len()]);
}
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(unix)]
fn fill_random(buffer: &mut [u8]) -> bool {
use std::io::Read;
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(buffer))
.is_ok()
}
#[cfg(windows)]
fn fill_random(buffer: &mut [u8]) -> bool {
#[link(name = "advapi32")]
unsafe extern "system" {
fn SystemFunction036(buffer: *mut u8, length: u32) -> u8;
}
buffer.is_empty() || unsafe { SystemFunction036(buffer.as_mut_ptr(), buffer.len() as u32) != 0 }
}
#[cfg(not(any(unix, windows)))]
fn fill_random(_buffer: &mut [u8]) -> bool {
false
}
pub fn url_encode(value: &str) -> String {
let mut out = String::new();
for b in value.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
pub fn url_decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(b) => {
out.push(b);
i += 3;
}
None => {
out.push(b'%');
i += 1;
}
}
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn traversal_is_removed() {
assert_eq!(sanitize_relative("../../etc/passwd"), vec!["etc", "passwd"]);
assert_eq!(sanitize_relative("..\\..\\x.txt"), vec!["x.txt"]);
assert_eq!(sanitize_relative("/abs/olute"), vec!["abs", "olute"]);
assert_eq!(sanitize_relative(".."), vec!["file"]);
assert_eq!(sanitize_component("a/b"), "a_b");
assert_eq!(sanitize_component(" ... "), "file");
assert_eq!(sanitize_component("C:evil"), "C_evil");
}
#[test]
fn long_names_keep_their_extension() {
let name = format!("{}.jpeg", "a".repeat(400));
let clean = sanitize_component(&name);
assert!(clean.len() <= 200 && clean.ends_with(".jpeg"));
}
#[test]
fn decode_round_trip() {
assert_eq!(url_decode(&url_encode("a b/c%d")), "a b/c%d");
assert_eq!(url_decode("100%"), "100%");
}
}