falcon_library/
weblink.rs1use std::fs;
6use std::io::Read;
7use std::path::Path;
8
9const MAX_BYTES: u64 = 1 << 20;
11
12pub fn is_link_file(ext: &str) -> bool {
14 ext.eq_ignore_ascii_case("url")
15}
16
17pub 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
32pub 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
42fn 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
58pub 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}