1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fs::OpenOptions;
9use std::io;
10use std::net::Ipv4Addr;
11use std::path::{Path, PathBuf};
12
13pub const PORT: u16 = 53317;
14pub const MULTICAST_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 167);
15pub const API: &str = "/api/localsend/v2";
16pub const VERSION: &str = "2.0";
17
18fn default_version() -> String {
19 VERSION.to_string()
20}
21fn default_port() -> u16 {
22 PORT
23}
24fn default_protocol() -> String {
25 "https".to_string()
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct DeviceInfo {
33 pub alias: String,
34 #[serde(default = "default_version")]
35 pub version: String,
36 #[serde(default)]
37 pub device_model: Option<String>,
38 #[serde(default)]
39 pub device_type: Option<String>,
40 #[serde(default)]
41 pub fingerprint: String,
42 #[serde(default = "default_port")]
43 pub port: u16,
44 #[serde(default = "default_protocol")]
45 pub protocol: String,
46 #[serde(default)]
47 pub download: bool,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub announce: Option<bool>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct FileMeta {
56 pub id: String,
57 pub file_name: String,
58 #[serde(default)]
59 pub size: u64,
60 #[serde(default)]
61 pub file_type: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub sha256: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub preview: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub metadata: Option<serde_json::Value>,
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct PrepareUploadRequest {
72 pub info: DeviceInfo,
73 pub files: BTreeMap<String, FileMeta>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub pulse: Option<PulseExtra>,
77}
78
79#[derive(Clone, Debug, Default, Serialize, Deserialize)]
81pub struct PulseExtra {
82 #[serde(default)]
84 pub clipboard: bool,
85}
86
87#[derive(Clone, Debug, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct PrepareUploadResponse {
90 pub session_id: String,
91 pub files: BTreeMap<String, String>,
92}
93
94pub fn is_message(files: &BTreeMap<String, FileMeta>) -> bool {
96 files.len() == 1
97 && files.values().all(|f| {
98 f.file_type == "text/plain" && f.preview.as_ref().is_some_and(|p| !p.is_empty())
99 })
100}
101
102pub fn sanitize_component(raw: &str) -> String {
107 let replaced: String = raw
108 .chars()
109 .map(|c| match c {
110 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
111 c if c.is_control() => '_',
112 c => c,
113 })
114 .collect();
115 let mut trimmed = replaced
116 .trim_matches(|c: char| c == '.' || c.is_whitespace())
117 .to_string();
118 if trimmed.is_empty() {
119 return "file".to_string();
120 }
121 if cfg!(windows) && is_windows_device(&trimmed) {
122 trimmed.insert(0, '_');
124 }
125 if trimmed.len() <= 200 {
126 return trimmed;
127 }
128 let (stem, ext) = split_extension(&trimmed);
129 let ext = if ext.len() <= 16 { ext } else { "" };
130 let mut end = 200usize.saturating_sub(ext.len()).min(stem.len());
131 while end > 0 && !stem.is_char_boundary(end) {
132 end -= 1;
133 }
134 let cut = stem[..end].trim_end_matches(|c: char| c == '.' || c.is_whitespace());
135 if cut.is_empty() {
136 "file".to_string()
137 } else {
138 format!("{cut}{ext}")
139 }
140}
141
142fn is_windows_device(name: &str) -> bool {
144 let stem = name.split('.').next().unwrap_or("").trim_end();
145 let upper = stem.to_ascii_uppercase();
146 matches!(
147 upper.as_str(),
148 "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
149 ) || ((upper.starts_with("COM") || upper.starts_with("LPT"))
150 && upper.len() == 4
151 && upper.as_bytes()[3].is_ascii_digit()
152 && upper.as_bytes()[3] != b'0')
153}
154
155pub fn sanitize_relative(raw: &str) -> Vec<String> {
158 let mut parts: Vec<String> = raw
159 .split(['/', '\\'])
160 .filter(|p| !p.is_empty() && *p != "." && *p != "..")
161 .map(sanitize_component)
162 .collect();
163 if parts.len() > 8 {
164 parts = parts.split_off(parts.len() - 8);
165 }
166 if parts.is_empty() {
167 parts.push("file".to_string());
168 }
169 parts
170}
171
172fn split_extension(name: &str) -> (&str, &str) {
173 match name.rfind('.') {
174 Some(dot) if dot > 0 => (&name[..dot], &name[dot..]),
175 _ => (name, ""),
176 }
177}
178
179pub fn reserve_unique(base: &Path, parts: &[String]) -> io::Result<PathBuf> {
183 let (dirs, last) = parts
184 .split_last()
185 .map(|(last, dirs)| (dirs, last))
186 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty file name"))?;
187 let mut dir = base.to_path_buf();
188 for part in dirs {
189 dir.push(sanitize_component(part));
190 }
191 std::fs::create_dir_all(&dir)?;
192 let last = sanitize_component(last);
193 let (stem, ext) = split_extension(&last);
194 for n in 0..10_000u32 {
195 let candidate = if n == 0 {
196 last.clone()
197 } else {
198 format!("{stem} ({n}){ext}")
199 };
200 let path = dir.join(candidate);
201 if !path.starts_with(base) {
202 return Err(io::Error::new(
203 io::ErrorKind::InvalidInput,
204 "path escapes the save folder",
205 ));
206 }
207 match OpenOptions::new().write(true).create_new(true).open(&path) {
208 Ok(_) => return Ok(path),
209 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
210 Err(e) => return Err(e),
211 }
212 }
213 Err(io::Error::new(
214 io::ErrorKind::AlreadyExists,
215 "too many files with that name",
216 ))
217}
218
219pub fn mime_for(name: &str) -> &'static str {
220 let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
221 match ext.as_str() {
222 "jpg" | "jpeg" => "image/jpeg",
223 "png" => "image/png",
224 "gif" => "image/gif",
225 "heic" => "image/heic",
226 "webp" => "image/webp",
227 "svg" => "image/svg+xml",
228 "mp4" | "m4v" => "video/mp4",
229 "mov" => "video/quicktime",
230 "mkv" => "video/x-matroska",
231 "mp3" => "audio/mpeg",
232 "m4a" => "audio/mp4",
233 "wav" => "audio/wav",
234 "pdf" => "application/pdf",
235 "zip" => "application/zip",
236 "json" => "application/json",
237 "txt" | "md" | "log" => "text/plain",
238 "html" | "htm" => "text/html",
239 "csv" => "text/csv",
240 _ => "application/octet-stream",
241 }
242}
243
244pub fn random_hex(n: usize) -> String {
248 let mut bytes = vec![0u8; n];
249 if !fill_random(&mut bytes) {
250 use std::hash::{BuildHasher, Hasher};
251 for chunk in bytes.chunks_mut(8) {
252 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
253 hasher.write_u128(
254 std::time::SystemTime::now()
255 .duration_since(std::time::UNIX_EPOCH)
256 .map(|d| d.as_nanos())
257 .unwrap_or(0),
258 );
259 let value = hasher.finish().to_le_bytes();
260 chunk.copy_from_slice(&value[..chunk.len()]);
261 }
262 }
263 bytes.iter().map(|b| format!("{b:02x}")).collect()
264}
265
266#[cfg(unix)]
267fn fill_random(buffer: &mut [u8]) -> bool {
268 use std::io::Read;
269 std::fs::File::open("/dev/urandom")
270 .and_then(|mut f| f.read_exact(buffer))
271 .is_ok()
272}
273
274#[cfg(windows)]
275fn fill_random(buffer: &mut [u8]) -> bool {
276 #[link(name = "advapi32")]
278 unsafe extern "system" {
279 fn SystemFunction036(buffer: *mut u8, length: u32) -> u8;
280 }
281 buffer.is_empty() || unsafe { SystemFunction036(buffer.as_mut_ptr(), buffer.len() as u32) != 0 }
283}
284
285#[cfg(not(any(unix, windows)))]
286fn fill_random(_buffer: &mut [u8]) -> bool {
287 false
288}
289
290pub fn url_encode(value: &str) -> String {
292 let mut out = String::new();
293 for b in value.bytes() {
294 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
295 out.push(b as char);
296 } else {
297 out.push_str(&format!("%{b:02X}"));
298 }
299 }
300 out
301}
302
303pub fn url_decode(value: &str) -> String {
304 let bytes = value.as_bytes();
305 let mut out = Vec::with_capacity(bytes.len());
306 let mut i = 0;
307 while i < bytes.len() {
308 match bytes[i] {
309 b'%' if i + 2 < bytes.len() => {
310 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
311 match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
312 Some(b) => {
313 out.push(b);
314 i += 3;
315 }
316 None => {
317 out.push(b'%');
318 i += 1;
319 }
320 }
321 }
322 b'+' => {
323 out.push(b' ');
324 i += 1;
325 }
326 b => {
327 out.push(b);
328 i += 1;
329 }
330 }
331 }
332 String::from_utf8_lossy(&out).into_owned()
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn traversal_is_removed() {
341 assert_eq!(sanitize_relative("../../etc/passwd"), vec!["etc", "passwd"]);
342 assert_eq!(sanitize_relative("..\\..\\x.txt"), vec!["x.txt"]);
343 assert_eq!(sanitize_relative("/abs/olute"), vec!["abs", "olute"]);
344 assert_eq!(sanitize_relative(".."), vec!["file"]);
345 assert_eq!(sanitize_component("a/b"), "a_b");
346 assert_eq!(sanitize_component(" ... "), "file");
347 assert_eq!(sanitize_component("C:evil"), "C_evil");
348 }
349
350 #[test]
351 fn long_names_keep_their_extension() {
352 let name = format!("{}.jpeg", "a".repeat(400));
353 let clean = sanitize_component(&name);
354 assert!(clean.len() <= 200 && clean.ends_with(".jpeg"));
355 }
356
357 #[test]
358 fn decode_round_trip() {
359 assert_eq!(url_decode(&url_encode("a b/c%d")), "a b/c%d");
360 assert_eq!(url_decode("100%"), "100%");
361 }
362}