Skip to main content

onelf_format/
desktop.rs

1//! Desktop Entry `Exec=` quoting and the icon set, shared by the packer's
2//! and the runtime's integrators so a binary under a path with a space
3//! gets one menu entry that launches, from either side.
4
5use crate::{EntryKind, Manifest, is_safe_component};
6
7/// Quote one `Exec=` field argument per the Desktop Entry spec, ready to
8/// write into the file: a value containing whitespace or a reserved
9/// character is double-quoted, with `"`, `` ` ``, `$`, and `\`
10/// backslash-escaped inside the quotes, and `%` becomes `%%` so it is not
11/// read as a field code. The result then goes through [`string_value`],
12/// because `Exec` is a string key whose escapes are undone before the
13/// quoting is: a literal backslash in a quoted argument takes four.
14pub fn exec_arg(s: &str) -> String {
15    let reserved = |c: char| c.is_whitespace() || "\"'\\<>~|&;$*?#()`".contains(c);
16    let quote = s.is_empty() || s.chars().any(reserved);
17    let mut out = String::with_capacity(s.len() + 2);
18    if quote {
19        out.push('"');
20    }
21    for c in s.chars() {
22        if quote && matches!(c, '"' | '`' | '$' | '\\') {
23            out.push('\\');
24        }
25        if c == '%' {
26            out.push('%');
27        }
28        out.push(c);
29    }
30    if quote {
31        out.push('"');
32    }
33    string_value(&out)
34}
35
36/// Escape `s` as a Desktop Entry string value: a backslash, newline, tab
37/// or carriage return becomes its backslash escape, and a leading space
38/// becomes `\s` so it is not trimmed. This is the whole encoding for a
39/// plain string key such as `TryExec`.
40pub fn string_value(s: &str) -> String {
41    let mut out = String::with_capacity(s.len());
42    for (i, c) in s.chars().enumerate() {
43        match c {
44            '\\' => out.push_str("\\\\"),
45            '\n' => out.push_str("\\n"),
46            '\t' => out.push_str("\\t"),
47            '\r' => out.push_str("\\r"),
48            ' ' if i == 0 => out.push_str("\\s"),
49            c => out.push(c),
50        }
51    }
52    out
53}
54
55/// Given the value of an `Exec=` line (everything after `Exec=`), the
56/// argument tail after the first argument (the executable), honouring
57/// double-quote quoting so a quoted or space-containing executable path
58/// is removed as one whole argument rather than split on its spaces.
59pub fn exec_arg_tail(value: &str) -> &str {
60    let bytes = value.as_bytes();
61    let mut i = 0;
62    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
63        i += 1;
64    }
65    if i < bytes.len() && bytes[i] == b'"' {
66        // Quoted argument: skip to the matching unescaped closing quote.
67        // The value is still string-escaped, so `\\` is one backslash,
68        // which in turn escapes whatever follows it.
69        i += 1;
70        while i < bytes.len() {
71            match bytes[i] {
72                b'\\' if i + 1 < bytes.len() => {
73                    let backslash = bytes[i + 1] == b'\\';
74                    i += 2;
75                    if backslash && i < bytes.len() {
76                        i += if bytes[i] == b'\\' && i + 1 < bytes.len() {
77                            2
78                        } else {
79                            1
80                        };
81                    }
82                }
83                b'"' => {
84                    i += 1;
85                    break;
86                }
87                _ => i += 1,
88            }
89        }
90    } else {
91        while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
92            i += 1;
93        }
94    }
95    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
96        i += 1;
97    }
98    &value[i..]
99}
100
101/// The `Exec=` command that launches `path`, through `entrypoint` when
102/// given. A package picks its entrypoint by the name it is run as, so an
103/// entrypoint the path would not reach is named in `ONELF_ENTRYPOINT`.
104pub fn exec_command(path: &str, entrypoint: Option<&str>) -> String {
105    match entrypoint {
106        Some(name) => format!(
107            "env {} {}",
108            exec_arg(&format!("{ENTRYPOINT_VAR}={name}")),
109            exec_arg(path)
110        ),
111        None => exec_arg(path),
112    }
113}
114
115/// The variable a desktop entry sets to launch a chosen entrypoint.
116const ENTRYPOINT_VAR: &str = "ONELF_ENTRYPOINT";
117
118/// `value` without the `env ONELF_ENTRYPOINT=...` an earlier patch wrote
119/// ahead of the binary, so re-patching replaces the binary and not `env`.
120fn without_entrypoint_prefix(value: &str) -> &str {
121    let Some(rest) = value.strip_prefix("env ") else {
122        return value;
123    };
124    let rest = rest.trim_start();
125    let token = rest.split_whitespace().next().unwrap_or_default();
126    if token.trim_start_matches('"').starts_with(ENTRYPOINT_VAR) {
127        rest[token.len()..].trim_start()
128    } else {
129        value
130    }
131}
132
133/// Patch an existing desktop file for this package's launch.
134///
135/// `Exec=` is rewritten to launch the package binary (quoting it as the
136/// spec requires), keeping any field codes or arguments that followed.
137/// `TryExec=` gets the path with only string escaping: the spec defines
138/// it as a literal path, not an Exec field, so field-code quoting does
139/// not apply.
140/// `Icon=` becomes `icon_name` when given. `entrypoint` is passed to
141/// [`exec_command`].
142///
143/// Each `[Desktop Action ...]` group's `Exec=` is rewritten the same way,
144/// keeping the action's own arguments, so a menu action launches the
145/// package as the entry does. Nothing else in an action is touched, and
146/// neither an action's command nor its presence decides what the entry
147/// group gets. Missing keys are inserted right after the
148/// `[Desktop Entry]` header, so they land in that group even when it is
149/// not the last one.
150pub fn patch_desktop_file(
151    content: &str,
152    exec_path: &str,
153    entrypoint: Option<&str>,
154    icon_name: Option<&str>,
155) -> String {
156    let quoted = exec_command(exec_path, entrypoint);
157    let try_exec = string_value(exec_path);
158    let mut lines: Vec<String> = content.lines().map(String::from).collect();
159    let mut has_exec = false;
160    let mut has_tryexec = false;
161    let mut in_entry = false;
162    let mut in_action = false;
163
164    for line in &mut lines {
165        let trimmed = line.trim_start();
166        if trimmed.starts_with('[') && trimmed.ends_with(']') {
167            in_entry = trimmed == "[Desktop Entry]";
168            in_action = trimmed.starts_with("[Desktop Action ");
169            continue;
170        }
171        if !in_entry && !in_action {
172            continue;
173        }
174        if line.starts_with("Exec=") {
175            // Replace the executable (the first argument), keeping the
176            // rest of the command line verbatim.
177            let tail = exec_arg_tail(without_entrypoint_prefix(&line[5..]));
178            if tail.is_empty() {
179                *line = format!("Exec={quoted}");
180            } else {
181                *line = format!("Exec={quoted} {tail}");
182            }
183            has_exec |= in_entry;
184        } else if in_action {
185            continue;
186        } else if line.starts_with("TryExec=") {
187            *line = format!("TryExec={try_exec}");
188            has_tryexec = true;
189        } else if line.starts_with("Icon=")
190            && let Some(name) = icon_name
191        {
192            *line = format!("Icon={name}");
193        }
194    }
195
196    let mut insert_at = lines
197        .iter()
198        .position(|l| l.trim() == "[Desktop Entry]")
199        .map(|h| h + 1)
200        .unwrap_or(lines.len());
201    if !has_exec {
202        lines.insert(insert_at, format!("Exec={quoted}"));
203        insert_at += 1;
204    }
205    if !has_tryexec {
206        lines.insert(insert_at, format!("TryExec={try_exec}"));
207    }
208
209    let mut result = lines.join("\n");
210    if !result.ends_with('\n') {
211        result.push('\n');
212    }
213    result
214}
215
216/// The `Icon=` of the `[Desktop Entry]` group, when it names a theme icon
217/// rather than a file.
218pub fn entry_icon(content: &str) -> Option<&str> {
219    let mut in_entry = false;
220    for line in content.lines() {
221        let trimmed = line.trim();
222        if trimmed.starts_with('[') && trimmed.ends_with(']') {
223            in_entry = trimmed == "[Desktop Entry]";
224        } else if in_entry && let Some(value) = line.strip_prefix("Icon=") {
225            let value = value.trim();
226            return (!value.is_empty() && !value.contains('/')).then_some(value);
227        }
228    }
229    None
230}
231
232/// One icon to install into the user's hicolor theme.
233pub struct ThemeIcon {
234    /// The theme's size directory, such as `48x48` or `scalable`.
235    pub size: String,
236    /// `png` or `svg`.
237    pub ext: &'static str,
238    pub data: Vec<u8>,
239}
240
241/// Every icon a package carries for one desktop entry.
242///
243/// That is each `icons/hicolor/<size>/apps/<name>.png` or `.svg` the
244/// package ships, for the first of `names` that has any, and the
245/// `.onelf/icons` icon `bundled` at the size its file gives. The latter is
246/// the one published for integration, so it wins over a theme icon of the
247/// same size. `read` returns an entry's verified bytes. A file that is
248/// neither SVG nor PNG is left out, since the theme would show a broken
249/// image.
250pub fn icon_set(
251    manifest: &Manifest,
252    names: &[&str],
253    bundled: Option<usize>,
254    mut read: impl FnMut(usize) -> std::io::Result<Vec<u8>>,
255) -> std::io::Result<Vec<ThemeIcon>> {
256    let paths: Vec<(usize, String)> = (0..manifest.entries.len())
257        .filter(|&i| manifest.entries[i].kind == EntryKind::File)
258        .map(|i| (i, manifest.entry_path(i)))
259        .collect();
260    let mut found: Vec<(String, &'static str, usize)> = Vec::new();
261    for name in names {
262        for (i, path) in &paths {
263            if let Some((size, ext)) = theme_slot(path, name)
264                && !found.iter().any(|(s, ..)| *s == size)
265            {
266                found.push((size.to_string(), ext, *i));
267            }
268        }
269        if !found.is_empty() {
270            break;
271        }
272    }
273    let mut icons = Vec::new();
274    for (size, ext, i) in found {
275        let data = read(i)?;
276        if ext == "svg" || png_dimensions(&data).is_some() {
277            icons.push(ThemeIcon { size, ext, data });
278        }
279    }
280    if let Some(i) = bundled {
281        let data = read(i)?;
282        let path = manifest.entry_path(i);
283        let slot = if path.ends_with(".svg") {
284            Some(("scalable".to_string(), "svg"))
285        } else {
286            png_dimensions(&data).map(|(w, h)| (format!("{w}x{h}"), "png"))
287        };
288        match slot {
289            Some((size, ext)) => {
290                icons.retain(|icon| icon.size != size);
291                icons.push(ThemeIcon { size, ext, data });
292            }
293            None => eprintln!("  Icon {path}: neither SVG nor a decodable PNG; not installed"),
294        }
295    }
296    Ok(icons)
297}
298
299/// The size directory and extension of `path` when it is
300/// `icons/hicolor/<size>/apps/<name>.png` or `.svg`, anywhere in the tree.
301fn theme_slot<'p>(path: &'p str, name: &str) -> Option<(&'p str, &'static str)> {
302    const THEME: &str = "icons/hicolor/";
303    let at = if path.starts_with(THEME) {
304        0
305    } else {
306        path.find("/icons/hicolor/")? + 1
307    };
308    let (size, file) = path[at + THEME.len()..].split_once("/apps/")?;
309    let ext = match file.strip_prefix(name)? {
310        ".png" => "png",
311        ".svg" => "svg",
312        _ => return None,
313    };
314    is_safe_component(size).then_some((size, ext))
315}
316
317/// Width and height from a PNG's IHDR chunk.
318fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
319    if data.len() < 24 || data[0..8] != *b"\x89PNG\r\n\x1a\n" {
320        return None;
321    }
322    let w = u32::from_be_bytes(data[16..20].try_into().ok()?);
323    let h = u32::from_be_bytes(data[20..24].try_into().ok()?);
324    Some((w, h))
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn exec_arg_tail_handles_quoting() {
333        assert_eq!(exec_arg_tail("/usr/bin/foo %U"), "%U");
334        assert_eq!(exec_arg_tail("\"/opt/my app/bin\" %F extra"), "%F extra");
335        assert_eq!(exec_arg_tail("/usr/bin/foo"), "");
336        assert_eq!(exec_arg_tail("\"/opt/my app/bin\""), "");
337        assert_eq!(exec_arg_tail("  /usr/bin/foo  %U"), "%U");
338    }
339
340    #[test]
341    fn exec_arg_quotes_only_what_needs_it() {
342        assert_eq!(exec_arg("/opt/app/bin"), "/opt/app/bin");
343        assert_eq!(exec_arg("/opt/my app/bin"), "\"/opt/my app/bin\"");
344        assert_eq!(exec_arg(""), "\"\"");
345    }
346
347    /// The exec escapes sit inside a string value, so each of their
348    /// backslashes is doubled, and `%` is doubled so it is not a field code.
349    #[test]
350    fn exec_arg_escapes_for_both_levels_and_field_codes() {
351        assert_eq!(exec_arg(r#"/a"b$c"#), r#""/a\\"b\\$c""#);
352        assert_eq!(exec_arg(r"/a\b"), r#""/a\\\\b""#);
353        assert_eq!(exec_arg("/opt/100%/app"), "/opt/100%%/app");
354        assert_eq!(exec_arg("/opt/50% off/app"), r#""/opt/50%% off/app""#);
355        assert_eq!(exec_arg("/a\nb"), r#""/a\nb""#);
356        assert_eq!(string_value(r"/a\b"), r"/a\\b");
357        assert_eq!(string_value(" a"), r"\sa");
358    }
359
360    /// What `exec_arg` writes, `exec_arg_tail` must read back as one
361    /// argument, or patching an already patched file splits it.
362    #[test]
363    fn exec_arg_tail_skips_what_exec_arg_writes() {
364        for path in [r#"/a"b c"#, r"/a\ b", r"/a b\", r#"/x y\"z"#] {
365            let line = format!("{} %U", exec_arg(path));
366            assert_eq!(exec_arg_tail(&line), "%U", "{line}");
367        }
368    }
369
370    #[test]
371    fn tryexec_is_string_escaped() {
372        let out = patch_desktop_file("[Desktop Entry]\n", r"/opt/a\b/app", None, None);
373        assert!(out.contains(r"TryExec=/opt/a\\b/app"), "{out}");
374        assert!(out.contains(r#"Exec="/opt/a\\\\b/app""#), "{out}");
375    }
376
377    #[test]
378    fn patch_replaces_exec_keeps_tail_and_icon() {
379        let desktop = "[Desktop Entry]\nType=Application\nExec=\"/opt/my app/bin\" %F\nIcon=old\n";
380        let out = patch_desktop_file(desktop, "/new/path/app", None, Some("newicon"));
381        assert!(out.contains("Exec=/new/path/app %F"), "got:\n{out}");
382        assert!(!out.contains("app/bin"), "stale path fragment left:\n{out}");
383        assert!(out.contains("Icon=newicon"));
384    }
385
386    #[test]
387    fn a_spaced_path_is_quoted_in_exec_and_literal_in_tryexec() {
388        let desktop = "[Desktop Entry]\nExec=/usr/bin/old\nTryExec=/usr/bin/old\n";
389        let out = patch_desktop_file(desktop, "/opt/has space/app", None, None);
390        assert!(out.contains("Exec=\"/opt/has space/app\"\n"), "got:\n{out}");
391        assert!(
392            out.contains("TryExec=/opt/has space/app\n"),
393            "TryExec is a literal path, not a quoted field: {out}"
394        );
395    }
396
397    #[test]
398    fn an_exec_in_an_action_group_does_not_speak_for_the_entry() {
399        // The entry carries no Exec; the action does. The missing keys
400        // must land in the entry group, not at EOF inside the action.
401        let desktop = "[Desktop Entry]\nType=Application\nName=App\n\
402                      [Desktop Action new-window]\nExec=/opt/old/app --new\n";
403        let out = patch_desktop_file(desktop, "/opt/new/app", None, None);
404        let entry_end = out.find("[Desktop Action").unwrap();
405        let entry = &out[..entry_end];
406        let action = &out[entry_end..];
407        assert!(
408            entry.contains("Exec=/opt/new/app\n"),
409            "the entry got its Exec: {out}"
410        );
411        assert!(
412            entry.contains("TryExec=/opt/new/app\n"),
413            "the entry got its TryExec: {out}"
414        );
415        assert!(
416            action.contains("Exec=/opt/new/app --new\n"),
417            "the action launches the package with its own arguments: {out}"
418        );
419        assert!(!action.contains("TryExec"), "{out}");
420    }
421
422    /// An action's Exec still named the command it was written with, so
423    /// every menu action of an integrated package failed to start.
424    #[test]
425    fn actions_launch_the_package_through_the_entrypoint() {
426        let desktop = "[Desktop Entry]\nExec=myapp %U\nActions=new;private;\n\
427                       [Desktop Action new]\nName=New\nExec=myapp --new-window %U\nIcon=keep\n\
428                       [Desktop Action private]\nExec=\"/opt/my app/app\" --private\n\
429                       [X-Other]\nExec=leave me\n";
430        let out = patch_desktop_file(desktop, "/opt/app.onelf", Some("daemon"), Some("icon"));
431        let launch = "env ONELF_ENTRYPOINT=daemon /opt/app.onelf";
432        assert!(out.contains(&format!("Exec={launch} %U\n")), "{out}");
433        assert!(
434            out.contains(&format!("Exec={launch} --new-window %U\n")),
435            "{out}"
436        );
437        assert!(out.contains(&format!("Exec={launch} --private\n")), "{out}");
438        assert!(
439            out.contains("Icon=keep\n"),
440            "an action's icon is its own: {out}"
441        );
442        assert!(
443            out.contains("Exec=leave me\n"),
444            "other groups are left: {out}"
445        );
446        assert_eq!(out.matches("TryExec=").count(), 1, "{out}");
447
448        let again = patch_desktop_file(&out, "/new/app.onelf", None, None);
449        assert!(
450            again.contains("Exec=/new/app.onelf --new-window %U\n"),
451            "{again}"
452        );
453        assert!(again.contains("Exec=/new/app.onelf --private\n"), "{again}");
454    }
455
456    #[test]
457    fn theme_icons_are_found_at_every_size() {
458        assert_eq!(
459            theme_slot("share/icons/hicolor/48x48/apps/app.png", "app"),
460            Some(("48x48", "png"))
461        );
462        assert_eq!(
463            theme_slot("icons/hicolor/scalable/apps/app.svg", "app"),
464            Some(("scalable", "svg"))
465        );
466        assert_eq!(
467            theme_slot("usr/share/icons/hicolor/32x32/apps/app.xpm", "app"),
468            None
469        );
470        assert_eq!(
471            theme_slot("share/icons/hicolor/48x48/apps/app2.png", "app"),
472            None
473        );
474        assert_eq!(
475            theme_slot("share/icons/hicolor/../apps/app.png", "app"),
476            None
477        );
478        assert_eq!(
479            entry_icon("[Desktop Entry]\nIcon=app\n[Desktop Action a]\nIcon=b\n"),
480            Some("app")
481        );
482        assert_eq!(entry_icon("[Desktop Entry]\nIcon=/opt/app.png\n"), None);
483    }
484
485    #[test]
486    fn a_chosen_entrypoint_is_named_in_the_launch() {
487        let desktop = "[Desktop Entry]\nName=App\nExec=old %U\n";
488        let out = patch_desktop_file(desktop, "/opt/app.onelf", Some("daemon"), None);
489        assert!(
490            out.contains("Exec=env ONELF_ENTRYPOINT=daemon /opt/app.onelf %U"),
491            "{out}"
492        );
493        assert!(out.contains("TryExec=/opt/app.onelf"), "{out}");
494
495        // Patching again, as a moved package does, replaces the binary
496        // after the prefix and keeps the field codes.
497        let again = patch_desktop_file(&out, "/new/app.onelf", Some("daemon"), None);
498        assert!(
499            again.contains("Exec=env ONELF_ENTRYPOINT=daemon /new/app.onelf %U"),
500            "{again}"
501        );
502        let plain = patch_desktop_file(&out, "/new/app.onelf", None, None);
503        assert!(plain.contains("Exec=/new/app.onelf %U"), "{plain}");
504    }
505}