freedesktop_desktop_entry/
decoder.rs

1// Copyright 2021 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4use std::{
5    fs::{self},
6    path::{Path, PathBuf},
7};
8
9use crate::{DesktopEntry, Group};
10use crate::{Groups, LocaleMap};
11use bstr::ByteSlice;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum DecodeError {
16    #[error("path does not contain a valid app ID")]
17    AppID,
18    #[error(transparent)]
19    Io(#[from] std::io::Error),
20    #[error("MultipleGroupWithSameName")]
21    MultipleGroupWithSameName,
22    #[error("KeyValueWithoutAGroup")]
23    KeyValueWithoutAGroup,
24    #[error("InvalidKey. Accepted: A-Za-z0-9")]
25    InvalidKey,
26    #[error("KeyDoesNotExist, this can happens when a localized key has no default value")]
27    KeyDoesNotExist,
28    #[error("InvalidValue")]
29    InvalidValue,
30}
31
32struct UnknownKey<'a> {
33    key: &'a str,
34    locale: String,
35    value: String,
36}
37
38impl DesktopEntry {
39    pub fn from_str<L>(
40        path: impl Into<PathBuf>,
41        input: &str,
42        locales_filter: Option<&[L]>,
43    ) -> Result<DesktopEntry, DecodeError>
44    where
45        L: AsRef<str>,
46    {
47        #[inline(never)]
48        fn inner<'a>(
49            path: PathBuf,
50            input: &'a str,
51            locales_filter: Option<Vec<&str>>,
52        ) -> Result<DesktopEntry, DecodeError> {
53            let path: PathBuf = path.into();
54
55            let appid = get_app_id(&path)?;
56
57            let mut groups = Groups::default();
58            let mut active_group: Option<ActiveGroup> = None;
59            let mut active_keys: Option<ActiveKeys> = None;
60            let mut ubuntu_gettext_domain = None;
61
62            let mut unknown_keys: Vec<UnknownKey> = Vec::new();
63
64            for line in input.lines() {
65                process_line(
66                    line,
67                    &mut groups,
68                    &mut active_group,
69                    &mut active_keys,
70                    &mut ubuntu_gettext_domain,
71                    locales_filter.as_deref(),
72                    &mut unknown_keys,
73                )?;
74            }
75
76            // insert keys which have no group
77            for unknown_key in unknown_keys.drain(..) {
78                match &mut active_group {
79                    Some(active_group) => match active_group.group.0.get_mut(unknown_key.key) {
80                        Some((_, locale_map)) => {
81                            locale_map.insert(unknown_key.locale, unknown_key.value);
82                        }
83                        None => return Err(DecodeError::KeyDoesNotExist),
84                    },
85                    None => return Err(DecodeError::KeyDoesNotExist),
86                }
87            }
88
89            if let Some(active_keys) = active_keys.take() {
90                match &mut active_group {
91                    Some(active_group) => {
92                        active_group.group.0.insert(
93                            active_keys.key_name,
94                            (active_keys.default_value, active_keys.locales),
95                        );
96                    }
97                    None => return Err(DecodeError::KeyValueWithoutAGroup),
98                }
99            }
100
101            if let Some(mut group) = active_group.take() {
102                groups
103                    .0
104                    .entry(group.group_name)
105                    .or_insert_with(|| Group::default())
106                    .0
107                    .append(&mut group.group.0);
108            }
109
110            Ok(DesktopEntry {
111                appid,
112                groups,
113                path,
114                ubuntu_gettext_domain,
115            })
116        }
117
118        inner(path.into(), input, locales_filter.map(add_generic_locales))
119    }
120
121    /// Return an owned [`DesktopEntry`]
122    #[inline]
123    pub fn from_path<L>(
124        path: impl Into<PathBuf>,
125        locales_filter: Option<&[L]>,
126    ) -> Result<DesktopEntry, DecodeError>
127    where
128        L: AsRef<str>,
129    {
130        let path: PathBuf = path.into();
131        let input = fs::read_to_string(&path)?;
132        Self::from_str(path, &input, locales_filter)
133    }
134}
135
136#[inline]
137fn get_app_id<P: AsRef<Path> + ?Sized>(path: &P) -> Result<String, DecodeError> {
138    let path_as_bytes = path
139        .as_ref()
140        .as_os_str()
141        .as_encoded_bytes()
142        .strip_suffix(b".desktop")
143        .ok_or(DecodeError::AppID)?;
144
145    Ok(
146        if let Some((_prefix, entry)) = path_as_bytes.rsplit_once_str("/applications/") {
147            String::from_utf8(entry.replace(b"/", b"-"))
148                .ok()
149                .ok_or(DecodeError::AppID)?
150        } else {
151            path.as_ref()
152                .file_stem()
153                .ok_or(DecodeError::AppID)?
154                .to_str()
155                .ok_or(DecodeError::AppID)?
156                .to_owned()
157        },
158    )
159}
160
161#[derive(Debug)]
162struct ActiveGroup {
163    group_name: String,
164    group: Group,
165}
166
167#[derive(Debug)]
168struct ActiveKeys {
169    key_name: String,
170    default_value: String,
171    locales: LocaleMap,
172}
173
174#[inline(never)]
175fn process_line<'a>(
176    line: &'a str,
177    groups: &mut Groups,
178    active_group: &mut Option<ActiveGroup>,
179    active_keys: &mut Option<ActiveKeys>,
180    ubuntu_gettext_domain: &mut Option<String>,
181    locales_filter: Option<&[&str]>,
182    unknown_keys: &mut Vec<UnknownKey<'a>>,
183) -> Result<(), DecodeError> {
184    if line.trim().is_empty() || line.starts_with('#') {
185        return Ok(());
186    }
187
188    let line_bytes = line.as_bytes();
189
190    // if group
191    if line_bytes[0] == b'[' {
192        // insert keys which have no group
193        for unknown_key in unknown_keys.drain(..) {
194            match active_group {
195                Some(active_group) => match active_group.group.0.get_mut(unknown_key.key) {
196                    Some((_, locale_map)) => {
197                        locale_map.insert(unknown_key.locale, unknown_key.value);
198                    }
199                    None => return Err(DecodeError::KeyDoesNotExist),
200                },
201                None => return Err(DecodeError::KeyDoesNotExist),
202            }
203        }
204
205        if let Some(end) = memchr::memrchr(b']', &line_bytes[1..]) {
206            let group_name = &line[1..end + 1];
207
208            if let Some(active_keys) = active_keys.take() {
209                match active_group {
210                    Some(active_group) => {
211                        active_group.group.0.insert(
212                            active_keys.key_name,
213                            (active_keys.default_value, active_keys.locales),
214                        );
215                    }
216                    None => return Err(DecodeError::KeyValueWithoutAGroup),
217                }
218            }
219
220            if let Some(mut group) = active_group.take() {
221                groups
222                    .0
223                    .entry(group.group_name)
224                    .or_insert_with(|| Group::default())
225                    .0
226                    .append(&mut group.group.0);
227            }
228
229            active_group.replace(ActiveGroup {
230                group_name: group_name.to_string(),
231                group: Group::default(),
232            });
233        }
234    }
235    // else, if value
236    else if let Some(delimiter) = memchr::memchr(b'=', line_bytes) {
237        let key = &line[..delimiter];
238        let value = format_value(&line[delimiter + 1..])?;
239
240        if key.is_empty() {
241            return Err(DecodeError::InvalidKey);
242        }
243
244        // if locale
245        if key.as_bytes()[key.len() - 1] == b']' {
246            if let Some(start) = memchr::memchr(b'[', key.as_bytes()) {
247                let locale = &key[start + 1..key.len() - 1];
248
249                let key = &key[..start];
250
251                match locales_filter {
252                    Some(locales_filter) if !locales_filter.iter().any(|l| *l == locale) => {
253                        return Ok(());
254                    }
255                    _ => (),
256                }
257
258                // we verify that the name is the same of active key
259                // even tho this is forbidden by the spec, nautilus does this for example
260                if let Some(active_keys) = active_keys
261                    .as_mut()
262                    .filter(|active_keys| active_keys.key_name == key)
263                {
264                    active_keys.locales.insert(locale.to_string(), value);
265                } else {
266                    unknown_keys.push(UnknownKey {
267                        key,
268                        locale: locale.to_string(),
269                        value,
270                    });
271                }
272
273                return Ok(());
274            }
275        }
276
277        if key == "X-Ubuntu-Gettext-Domain" {
278            *ubuntu_gettext_domain = Some(value.to_string());
279            return Ok(());
280        }
281
282        if let Some(active_keys) = active_keys.take() {
283            match active_group {
284                Some(active_group) => {
285                    active_group.group.0.insert(
286                        active_keys.key_name,
287                        (active_keys.default_value, active_keys.locales),
288                    );
289                }
290                None => return Err(DecodeError::KeyValueWithoutAGroup),
291            }
292        }
293        active_keys.replace(ActiveKeys {
294            // todo: verify that the key only contains A-Za-z0-9 ?
295            key_name: key.trim().to_string(),
296            default_value: value,
297            locales: LocaleMap::default(),
298        });
299    }
300    Ok(())
301}
302
303// https://specifications.freedesktop.org/desktop-entry-spec/latest/value-types.html
304#[inline]
305fn format_value(input: &str) -> Result<String, DecodeError> {
306    let input = if let Some(input) = input.strip_prefix(" ") {
307        input
308    } else {
309        input
310    };
311
312    let mut res = String::with_capacity(input.len());
313
314    let mut last: usize = 0;
315
316    for i in memchr::memchr_iter(b'\\', input.as_bytes()) {
317        // edge case for //
318        if last > i {
319            continue;
320        }
321
322        // when there is an \ at the end
323        if input.len() <= i + 1 {
324            return Err(DecodeError::InvalidValue);
325        }
326
327        if last < i {
328            res.push_str(&input[last..i]);
329        }
330
331        last = i + 2;
332
333        match input.as_bytes()[i + 1] {
334            b's' => res.push(' '),
335            b'n' => res.push('\n'),
336            b't' => res.push('\t'),
337            b'r' => res.push('\r'),
338            b'\\' => res.push('\\'),
339            _ => {
340                return Err(DecodeError::InvalidValue);
341            }
342        }
343    }
344
345    if last < input.len() {
346        res.push_str(&input[last..input.len()]);
347    }
348
349    Ok(res)
350}
351
352/// Ex: if a locale equal fr_FR, add fr
353#[inline]
354fn add_generic_locales<L: AsRef<str>>(locales: &[L]) -> Vec<&str> {
355    let mut v = Vec::with_capacity(locales.len() + 1);
356
357    for l in locales {
358        let l = l.as_ref();
359
360        v.push(l);
361
362        if let Some(start) = memchr::memchr(b'_', l.as_bytes()) {
363            v.push(l.split_at(start).0)
364        }
365    }
366
367    v
368}