Skip to main content

rightkit_send/
proto.rs

1//! LocalSend protocol v2 wire types and the file-name rules for received files.
2//!
3//! Implemented independently from the open specification
4//! (github.com/localsend/protocol, README.md, v2.2); no LocalSend code is used.
5
6use 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/// What a device says about itself: the multicast announcement, `/register`
29/// and `/info` bodies, and `info` inside `prepare-upload`.
30#[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/// One file in a `prepare-upload` request.
53#[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    /// Pulse-only extras; other LocalSend implementations ignore the key.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub pulse: Option<PulseExtra>,
77}
78
79/// Pulse's additions to a `prepare-upload` request.
80#[derive(Clone, Debug, Default, Serialize, Deserialize)]
81pub struct PulseExtra {
82    /// The send came from the clipboard and should land on the receiver's.
83    #[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
94/// A text sent as a message is one `text/plain` file with its text in `preview`.
95pub 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
102// ---- file names ------------------------------------------------------------
103
104/// One path component made safe: no separators, no control characters, no
105/// leading or trailing dots or spaces (so never `.` or `..`), at most 200 bytes.
106pub 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        // CON, NUL, COM1 … name devices on Windows, with or without an extension.
123        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
142/// A reserved Windows device name (`NUL`, `com1.txt`, …).
143fn 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
155/// A sender's `fileName` as safe components below the save folder. `..`, `.`,
156/// empty parts and both separators never survive; at most the last 8 parts.
157pub 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
179/// Create (empty) the file `parts` names below `base`, choosing "name (1).ext",
180/// "name (2).ext", … when it exists. `create_new` makes the choice atomic and
181/// never follows a symlink, so an existing file is never overwritten.
182pub 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
244/// `n` random bytes as lowercase hex (session ids, tokens, file ids).
245/// Unix reads `/dev/urandom`; elsewhere the standard library's per-process
246/// random hasher keys are mixed with the clock.
247pub 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    // RtlGenRandom, which the standard library's own random keys also use.
277    #[link(name = "advapi32")]
278    unsafe extern "system" {
279        fn SystemFunction036(buffer: *mut u8, length: u32) -> u8;
280    }
281    // SAFETY: `buffer` is a live, writable slice of the stated length.
282    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
290/// Percent-encode a query value.
291pub 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}