Skip to main content

falcon_library/
weblink.rs

1//! Reading the address out of link files: Windows `.url` shortcuts (an INI
2//! file), the link format in Eagle's list. A macOS `.webloc` is a plain
3//! item, as in Eagle.
4
5use std::fs;
6use std::io::Read;
7use std::path::Path;
8
9/// Link files bigger than this are not read.
10const MAX_BYTES: u64 = 1 << 20;
11
12/// Whether an extension names a link file (`url`, any case).
13pub fn is_link_file(ext: &str) -> bool {
14    ext.eq_ignore_ascii_case("url")
15}
16
17/// Reads the address from a link file by its extension. Files over 1 MiB,
18/// unreadable files and files without an address give `None`.
19pub fn read(path: &Path) -> Option<String> {
20    let ext = path.extension()?.to_str()?;
21    if !is_link_file(ext) {
22        return None;
23    }
24    let mut data = Vec::new();
25    fs::File::open(path).ok()?.take(MAX_BYTES + 1).read_to_end(&mut data).ok()?;
26    if data.len() as u64 > MAX_BYTES {
27        return None;
28    }
29    parse(ext, &data)
30}
31
32/// Parses link file contents, picking the format from the extension.
33pub fn parse(ext: &str, data: &[u8]) -> Option<String> {
34    if ext.eq_ignore_ascii_case("url") { parse_internet_shortcut(data) } else { None }
35}
36
37fn non_empty(s: &str) -> Option<String> {
38    let s = s.trim();
39    (!s.is_empty()).then(|| s.to_string())
40}
41
42/// Decodes the text of a `.url` file: UTF-16 with a byte order mark, UTF-8
43/// with or without one.
44fn decode_text(data: &[u8]) -> String {
45    let utf16 = |rest: &[u8], le: bool| {
46        let units: Vec<u16> =
47            rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }).collect();
48        String::from_utf16_lossy(&units)
49    };
50    match data {
51        [0xFF, 0xFE, rest @ ..] => utf16(rest, true),
52        [0xFE, 0xFF, rest @ ..] => utf16(rest, false),
53        [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
54        _ => String::from_utf8_lossy(data).into_owned(),
55    }
56}
57
58/// The address of a Windows `.url` file: the first `URL=` key in the
59/// `[InternetShortcut]` section. Section names and keys ignore case, and
60/// keys in other sections are skipped.
61pub fn parse_internet_shortcut(data: &[u8]) -> Option<String> {
62    let text = decode_text(data);
63    let mut inside = false;
64    for line in text.lines() {
65        let line = line.trim();
66        if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
67            inside = name.trim().eq_ignore_ascii_case("InternetShortcut");
68        } else if inside
69            && let Some((key, value)) = line.split_once('=')
70            && key.trim().eq_ignore_ascii_case("URL")
71        {
72            return non_empty(value);
73        }
74    }
75    None
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn link_file_extensions() {
84        assert!(is_link_file("url") && is_link_file("URL") && is_link_file("Url"));
85        assert!(!is_link_file("webloc"), "a .webloc is a plain item, as in Eagle");
86        assert!(!is_link_file("txt") && !is_link_file(""));
87    }
88
89    #[test]
90    fn a_url_file_with_crlf_line_ends() {
91        let data = b"[InternetShortcut]\r\nURL=https://example.com/x?a=b\r\nIconIndex=0\r\n";
92        assert_eq!(parse_internet_shortcut(data).as_deref(), Some("https://example.com/x?a=b"));
93    }
94
95    #[test]
96    fn a_url_file_with_lowercase_keys_and_spaces() {
97        let data = b"  [ internetshortcut ]\nurl = https://example.com/  \n";
98        assert_eq!(parse_internet_shortcut(data).as_deref(), Some("https://example.com/"));
99    }
100
101    #[test]
102    fn keys_in_other_sections_are_ignored() {
103        let data = b"[Before]\nURL=https://wrong.example/\n[InternetShortcut]\nIconFile=x\nURL=https://right.example/\nURL=https://second.example/\n[After]\nURL=https://wrong2.example/\n";
104        assert_eq!(parse_internet_shortcut(data).as_deref(), Some("https://right.example/"));
105        assert_eq!(parse_internet_shortcut(b"[Other]\nURL=https://a.example/\n"), None);
106        assert_eq!(parse_internet_shortcut(b"URL=https://a.example/\n"), None);
107    }
108
109    #[test]
110    fn a_url_file_with_a_utf8_bom() {
111        let mut data = vec![0xEF, 0xBB, 0xBF];
112        data.extend_from_slice("[InternetShortcut]\nURL=https://example.com/ü\n".as_bytes());
113        assert_eq!(parse_internet_shortcut(&data).as_deref(), Some("https://example.com/ü"));
114    }
115
116    #[test]
117    fn a_url_file_in_utf16() {
118        let text = "[InternetShortcut]\r\nURL=https://example.com/ü\r\n";
119        let mut le = vec![0xFF, 0xFE];
120        le.extend(text.encode_utf16().flat_map(|u| u.to_le_bytes()));
121        let mut be = vec![0xFE, 0xFF];
122        be.extend(text.encode_utf16().flat_map(|u| u.to_be_bytes()));
123        assert_eq!(parse_internet_shortcut(&le).as_deref(), Some("https://example.com/ü"));
124        assert_eq!(parse_internet_shortcut(&be).as_deref(), Some("https://example.com/ü"));
125    }
126
127    #[test]
128    fn an_empty_address_is_none() {
129        assert_eq!(parse_internet_shortcut(b"[InternetShortcut]\nURL=   \n"), None);
130        assert_eq!(parse_internet_shortcut(b""), None);
131    }
132
133    #[test]
134    fn parse_dispatches_on_the_extension() {
135        assert!(parse("URL", b"[InternetShortcut]\nURL=https://a.example/\n").is_some());
136        assert_eq!(parse("webloc", b"[InternetShortcut]\nURL=https://a.example/\n"), None);
137        assert_eq!(parse("txt", b"[InternetShortcut]\nURL=https://a.example/\n"), None);
138    }
139
140    #[test]
141    fn read_checks_the_size_and_the_extension() {
142        let tmp = tempfile::tempdir().unwrap();
143        let small = tmp.path().join("a.URL");
144        fs::write(&small, "[InternetShortcut]\nURL=https://a.example/\n").unwrap();
145        assert_eq!(read(&small).as_deref(), Some("https://a.example/"));
146
147        let mut big = b"[InternetShortcut]\nURL=https://a.example/\n;".to_vec();
148        big.resize(MAX_BYTES as usize + 1, b'x');
149        let path = tmp.path().join("big.url");
150        fs::write(&path, &big).unwrap();
151        assert_eq!(read(&path), None);
152        big.truncate(MAX_BYTES as usize);
153        fs::write(&path, &big).unwrap();
154        assert!(read(&path).is_some());
155
156        let other = tmp.path().join("a.txt");
157        fs::write(&other, "[InternetShortcut]\nURL=https://a.example/\n").unwrap();
158        assert_eq!(read(&other), None);
159        assert_eq!(read(&tmp.path().join("missing.url")), None);
160    }
161}