cargo_deb/dh/
dh_installsystemd.rs

1/// This module is a partial implementation of the Debian `DebHelper` command
2/// for properly installing systemd units as part of a .deb package install aka
3/// `dh_installsystemd`. Specifically this implementation is based on the Ubuntu
4/// version labelled 12.10ubuntu1 which is included in Ubuntu 20.04 LTS. For
5/// more details on the source version see the comments in `dh_lib.rs`.
6///
7/// # See also
8///
9/// Ubuntu 20.04 `dh_installsystemd` sources:
10/// <https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1>
11///
12/// Ubuntu 20.04 `dh_installsystemd` man page (online HTML version):
13/// <http://manpages.ubuntu.com/manpages/focal/en/man1/dh_installsystemd.1.html>
14use itertools::Itertools; // for .next_tuple()
15use std::collections::{BTreeSet, HashMap};
16use std::io::prelude::*;
17use std::path::{Path, PathBuf};
18use std::str;
19
20use crate::assets::Asset;
21use crate::dh::dh_lib::{autoscript, pkgfile, ScriptFragments};
22use crate::listener::Listener;
23use crate::util::{fname_from_path, MyJoin};
24use crate::{CDResult, CargoDebError};
25
26/// From `man 1 dh_installsystemd` on Ubuntu 20.04 LTS. See:
27///   <http://manpages.ubuntu.com/manpages/focal/en/man1/dh_installsystemd.1.html>
28/// FILES
29///        debian/package.mount, debian/package.path, debian/package@.path,
30///        debian/package.service, debian/package@.service,
31///        debian/package.socket, debian/package@.socket, debian/package.target,
32///        debian/package@.target, debian/package.timer, debian/package@.timer
33///            If any of those files exists, they are installed into
34///            lib/systemd/system/ in the package build directory.
35///        debian/package.tmpfile
36///            Only used in compat 12 or earlier.  In compat 13+, this file is
37///            handled by `dh_installtmpfiles(1)` instead.
38///            If this exists, it is installed into usr/lib/tmpfiles.d/ in the
39///            package build directory. Note that the "tmpfiles.d" mechanism is
40///            currently only used by systemd.
41const LIB_SYSTEMD_SYSTEM_DIR: &str = "usr/lib/systemd/system/";
42const USR_LIB_TMPFILES_D_DIR: &str = "usr/lib/tmpfiles.d/";
43const SYSTEMD_UNIT_FILE_INSTALL_MAPPINGS: [(&str, &str, &str); 12] = [
44    ("",  "mount",   LIB_SYSTEMD_SYSTEM_DIR),
45    ("",  "path",    LIB_SYSTEMD_SYSTEM_DIR),
46    ("@", "path",    LIB_SYSTEMD_SYSTEM_DIR),
47    ("",  "service", LIB_SYSTEMD_SYSTEM_DIR),
48    ("@", "service", LIB_SYSTEMD_SYSTEM_DIR),
49    ("",  "socket",  LIB_SYSTEMD_SYSTEM_DIR),
50    ("@", "socket",  LIB_SYSTEMD_SYSTEM_DIR),
51    ("",  "target",  LIB_SYSTEMD_SYSTEM_DIR),
52    ("@", "target",  LIB_SYSTEMD_SYSTEM_DIR),
53    ("",  "timer",   LIB_SYSTEMD_SYSTEM_DIR),
54    ("@", "timer",   LIB_SYSTEMD_SYSTEM_DIR),
55    ("",  "tmpfile", USR_LIB_TMPFILES_D_DIR),
56];
57
58#[derive(Debug, PartialEq, Eq)]
59pub struct InstallRecipe {
60    pub path: PathBuf,
61    pub mode: u32,
62}
63
64pub type PackageUnitFiles = HashMap<PathBuf, InstallRecipe>;
65
66/// From `man 1 dh_installsystemd` on Ubuntu 20.04 LTS. See:
67///   <http://manpages.ubuntu.com/manpages/focal/en/man1/dh_installsystemd.1.html>
68/// > --no-enable
69/// > Disable the service(s) on purge, but do not enable them on install.
70/// >
71/// > Note that this option does not affect whether the services are started.  Please
72/// > remember to also use --no-start if the service should not be started.
73/// >
74/// > --name=name
75/// > This option controls several things.
76/// >
77/// > It changes the name that `dh_installsystemd` uses when it looks for maintainer provided
78/// > systemd unit files as listed in the "FILES" section.  As an example, `dh_installsystemd`
79/// > --name foo will look for debian/package.foo.service instead of
80/// > debian/package.service).  These unit files are installed as name.unit-extension (in
81/// > the example, it would be installed as foo.service).
82/// >
83/// > Furthermore, if no unit files are passed explicitly as command line arguments,
84/// > `dh_installsystemd` will only act on unit files called name (rather than all unit files
85/// > found in the package).
86/// >
87/// > --restart-after-upgrade
88/// > Do not stop the unit file until after the package upgrade has been completed.  This is
89/// > the default behaviour in compat 10.
90/// >
91/// > In earlier compat levels the default was to stop the unit file in the prerm, and start
92/// > it again in the postinst.
93/// >
94/// > This can be useful for daemons that should not have a possibly long downtime during
95/// > upgrade. But you should make sure that the daemon will not get confused by the package
96/// > being upgraded while it's running before using this option.
97/// >
98/// > --no-restart-after-upgrade
99/// > Undo a previous --restart-after-upgrade (or the default of compat 10).  If no other
100/// > options are given, this will cause the service to be stopped in the prerm script and
101/// > started again in the postinst script.
102/// >
103/// > -r, --no-stop-on-upgrade, --no-restart-on-upgrade
104/// > Do not stop service on upgrade.
105/// >
106/// > --no-start
107/// > Do not start the unit file after upgrades and after initial installation (the latter
108/// > is only relevant for services without a corresponding init script).
109/// >
110/// > Note that this option does not affect whether the services are enabled.  Please
111/// > remember to also use --no-enable if the services should not be enabled.
112/// >
113/// > unit file ...
114/// > Only process and generate maintscripts for the installed unit files with the
115/// > (base)name unit file.
116/// >
117/// > Note: `dh_installsystemd` will still install unit files from debian/ but it will not
118/// > generate any maintscripts for them unless they are explicitly listed in unit file ...
119#[derive(Default, Debug)]
120pub struct Options {
121    pub no_enable: bool,
122    pub no_start: bool,
123    pub restart_after_upgrade: bool,
124    pub no_stop_on_upgrade: bool,
125}
126
127/// Find installable systemd unit files for the specified debian package (and
128/// optional systemd unit name) in the given directory and return an install
129/// recipe for each file detailing the path at which the file should be
130/// installed and the mode (chmod) that the file should be given.
131///
132/// See:
133///   <https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n264>
134///   <https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n198>
135///   <https://git.launchpad.net/ubuntu/+source/debhelper/tree/lib/Debian/Debhelper/Dh_Lib.pm?h=applied/12.10ubuntu1#n957>
136pub fn find_units(dir: &Path, main_package: &str, unit_name: Option<&str>) -> PackageUnitFiles {
137    let mut installables = HashMap::new();
138
139    for (package_suffix, unit_type, install_dir) in &SYSTEMD_UNIT_FILE_INSTALL_MAPPINGS {
140        let package_name = &format!("{main_package}{package_suffix}");
141        if let Some(src_path) = pkgfile(dir, main_package, package_name, unit_type, unit_name) {
142            // .tmpfile files should be installed in a different directory and
143            // with a different extension. See:
144            //   https://www.freedesktop.org/software/systemd/man/tmpfiles.d.html
145            let actual_suffix = match &unit_type[..] {
146                "tmpfile" => "conf",
147                _ => unit_type,
148            };
149
150            // Determine the file name that the unit file should be installed as
151            // which depends on whether or not a unit name was provided.
152            let install_filename = if let Some(unit_name) = unit_name {
153                format!("{unit_name}{package_suffix}.{actual_suffix}")
154            } else {
155                format!("{package_name}.{actual_suffix}")
156            };
157
158            // Construct the full install path for this unit file.
159            let install_path = Path::new(install_dir).join(install_filename);
160
161            // Save the combination of source path, target path and target file
162            // mode for this unit file.
163            installables.insert(src_path, InstallRecipe {
164                path: install_path,
165                mode: 0o644,
166            });
167        }
168    }
169
170    installables
171}
172
173/// Determine if the given string is a systemd unit file comment line.
174///
175/// See:
176///   <https://www.freedesktop.org/software/systemd/man/systemd.syntax.html#Introduction>
177fn is_comment(s: &str) -> bool {
178    matches!(s.chars().next(), Some('#' | ';'))
179}
180
181/// Strip off any first layer of outer quotes according to systemd quoting
182/// rules.
183///
184/// See:
185///   <https://www.freedesktop.org/software/systemd/man/systemd.service.html#Command%20lines>
186fn unquote(s: &str) -> &str {
187    if s.len() > 1 &&
188       ((s.starts_with('"') && s.ends_with('"')) ||
189       (s.starts_with('\'') && s.ends_with('\''))) {
190        &s[1..s.len()-1]
191    } else {
192        s
193    }
194}
195
196/// This function implements the primary logic of the Debian `dh_installsystemd`
197/// Perl script, which is to say it identifies systemd units being installed,
198/// inspects them and decides, based on the unit file and the configuration
199/// options provided, which `DebHelper` autoscripts to use to correctly install
200/// those units.
201///
202/// # Cargo Deb specific behaviour
203///
204/// Any `Asset`, whether identified by `find_units()` or added by the user
205/// manually in Cargo.toml, that will be installed into `LIB_SYSTEMD_SYSTEM_DIR`
206/// will be analysed.
207///
208/// Unlike `dh_installsystemd` results are returned as a `ScriptFragments` value
209/// rather than being written to temporary files on disk.
210///
211/// # Usage
212///
213/// Pass the `ScriptFragments` result to `apply()`.
214///
215/// See:
216///   <https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n288>
217pub fn generate(package: &str, assets: &[Asset], options: &Options, listener: &dyn Listener) -> CDResult<ScriptFragments> {
218    let mut scripts = ScriptFragments::new();
219
220    // add postinst code blocks to handle tmpfiles
221    // see: <https://salsa.debian.org/debian/debhelper/-/blob/master/dh_installsystemd#L305>
222    // tmpfiles are installed with .conf as extension
223    // <https://www.freedesktop.org/software/systemd/man/tmpfiles.d.html>
224    let tmp_file_names = assets
225        .iter()
226        .filter(|a| a.c.target_path.starts_with(USR_LIB_TMPFILES_D_DIR))
227        .map(|v| {
228            v.source.path()
229                .and_then(|p| fname_from_path(&p.with_extension("conf")))
230                .ok_or(CargoDebError::Str("dh_installsystemd: invalid source path"))
231        })
232        .collect::<CDResult<Vec<String>>>()?
233        .join(" ");
234
235    if !tmp_file_names.is_empty() {
236        autoscript(&mut scripts, package, "postinst", "postinst-init-tmpfiles",
237            &map!{ "TMPFILES" => tmp_file_names }, false, listener)?;
238    }
239
240    // add postinst, prerm, and postrm code blocks to handle activation,
241    // deactivation, start and stopping of services when the package is
242    // installed, upgraded or removed.
243    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n312
244
245    // skip template service files. Enabling, disabling, starting or stopping
246    // those services without specifying the instance is not useful.
247    let mut installed_non_template_units: BTreeSet<String> = BTreeSet::new();
248    installed_non_template_units.extend(
249        assets
250            .iter()
251            .filter(|a| a.c.target_path.parent() == Some(LIB_SYSTEMD_SYSTEM_DIR.as_ref()))
252            .filter_map(|a| fname_from_path(a.c.target_path.as_path()))
253            .filter(|fname| !fname.contains('@')),
254    );
255
256    // BTreeSets values iterate in sorted order irrespective of the order they
257    // were inserted.
258    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n385
259    let mut enable_units = BTreeSet::new();
260    let mut start_units = BTreeSet::new();
261    let mut seen = BTreeSet::new();
262
263    // note: we do not support handling of services with a sysv-equivalent
264    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n373
265    let mut units = installed_non_template_units;
266
267    // for all installed non-template units and any units they refer to via
268    // the 'Also=' key in their unit file, determine what if anything we need to
269    // arrange to be done for them in the maintainer scripts.
270    while !units.is_empty() {
271        // gather unit names mentioned in 'Also=' kv pairs in the unit files
272        let mut also_units = BTreeSet::<String>::new();
273
274        // for each unit that we have not yet processed
275        for unit in &units {
276            listener.progress("Checking", format!("augmentations needed for systemd unit {unit}"));
277
278            // the unit has to be started
279            start_units.insert(unit.clone());
280
281            // get the unit file contents
282            let needle = Path::new(LIB_SYSTEMD_SYSTEM_DIR).join(unit);
283            let data = assets.iter().find(move |&item| item.c.target_path == needle).unwrap().source.data()?;
284            let reader = data.into_owned();
285
286            // for every line in the file look for specific keys that we are
287            // interested in:
288            // From: https://www.freedesktop.org/software/systemd/man/systemd.syntax.html
289            //   "Each file is a plain text file divided into sections, with
290            //    configuration entries in the style key=value. Whitespace
291            //    immediately before or after the "=" is ignored. Empty lines
292            //    and lines starting with "#" or ";" are ignored which may be
293            //    used for commenting."
294            //   "Various settings are allowed to be specified more than
295            //    once"
296            // Key names _seem_ to be case sensitive. It's not explicitly
297            // stated in systemd.syntax.html above but this bug report seems
298            // to confirm it:
299            //   https://bugzilla.redhat.com/show_bug.cgi?id=846283
300            // We also strip the value of any surrounding quotes because
301            // that's what the actual dh_installsystemd code does:
302            //   https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n210
303            for line in reader.lines().map(|line| line.unwrap()).filter(|s| !is_comment(s)) {
304                let possible_kv_pair = line.splitn(2, '=').map(|s| s.trim()).next_tuple();
305                if let Some((key, value)) = possible_kv_pair {
306                    let other_unit = unquote(value).to_string();
307                    match key {
308                        "Also" => {
309                            // The seen lookup prevents us from looping forever over
310                            // unit files that refer to each other. An actual
311                            // real-world example of such a loop is systemd's
312                            // systemd-readahead-drop.service, which contains
313                            // Also=systemd-readahead-collect.service, and that file
314                            // in turn contains Also=systemd-readahead-drop.service,
315                            // thus forming an endless loop.
316                            // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n340
317                            if seen.insert(other_unit.clone()) {
318                                also_units.insert(other_unit);
319                            }
320                        },
321                        "Alias" => {
322                            // TODO?
323                        },
324                        _ => (),
325                    }
326                } else if line.starts_with("[Install]") {
327                    enable_units.insert(unit.clone());
328                }
329            }
330        }
331        units = also_units;
332    }
333
334    // update the maintainer scripts to enable units unless forbidden by the
335    // options passed to us.
336    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n390
337    if !enable_units.is_empty() {
338        let snippet = if options.no_enable { "postinst-systemd-dont-enable" } else { "postinst-systemd-enable" };
339        for unit in &enable_units {
340            autoscript(&mut scripts, package, "postinst", snippet,
341                &map!{ "UNITFILE" => unit.clone() }, true, listener)?;
342        }
343        autoscript(&mut scripts, package, "postrm", "postrm-systemd",
344            &map!{ "UNITFILES" => enable_units.join(" ") }, false, listener)?;
345    }
346
347    // update the maintainer scripts to start units, where the exact action to
348    // be taken is influenced by the options passed to us.
349    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n398
350    if !start_units.is_empty() {
351        let mut replace = map! { "UNITFILES" => start_units.join(" ") };
352
353        if options.restart_after_upgrade {
354            let snippet = if options.no_start {
355                replace.insert("RESTART_ACTION", "try-restart".into());
356                "postinst-systemd-restartnostart"
357            } else {
358                replace.insert("RESTART_ACTION", "restart".into());
359                "postinst-systemd-restart"
360            };
361            autoscript(&mut scripts, package, "postinst", snippet, &replace, true, listener)?;
362        } else if !options.no_start {
363            // (stop|start) service (before|after) upgrade
364            autoscript(&mut scripts, package, "postinst", "postinst-systemd-start", &replace, true, listener)?;
365        }
366
367        if options.no_stop_on_upgrade || options.restart_after_upgrade {
368            // stop service only on remove
369            autoscript(&mut scripts, package, "prerm", "prerm-systemd-restart", &replace, true, listener)?;
370        } else if !options.no_start {
371            // always stop service
372            autoscript(&mut scripts, package, "prerm", "prerm-systemd", &replace, true, listener)?;
373        }
374
375        // Run this with "default" order so it is always after other service
376        // related autosnippets.
377        autoscript(&mut scripts, package, "postrm", "postrm-systemd-reload-only", &replace, false, listener)?;
378    }
379
380    Ok(scripts)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::assets::{Asset, AssetKind, AssetSource, IsBuilt};
387    use crate::util::tests::{add_test_fs_paths, get_read_count, set_test_fs_path_content};
388    use rstest::*;
389
390    #[test]
391    fn is_comment_detects_comments() {
392        assert!(is_comment("#"));
393        assert!(is_comment("#  "));
394        assert!(is_comment("# some comment"));
395        assert!(is_comment(";"));
396        assert!(is_comment(";  "));
397        assert!(is_comment("; some comment"));
398    }
399
400    #[test]
401    fn is_comment_detects_non_comments() {
402        assert!(!is_comment(" #"));
403        assert!(!is_comment(" #  "));
404        assert!(!is_comment(" # some comment"));
405        assert!(!is_comment(" ;"));
406        assert!(!is_comment(" ;  "));
407        assert!(!is_comment(" ; some comment"));
408    }
409
410    #[test]
411    fn unquote_unquotes_matching_single_quotes() {
412        assert_eq!("", unquote("''"));
413        assert_eq!("a", unquote("'a'"));
414        assert_eq!("ab", unquote("'ab'"));
415    }
416
417    #[test]
418    fn unquote_unquotes_matching_double_quotes() {
419        assert_eq!("", unquote(r#""""#));
420        assert_eq!("a", unquote(r#""a""#));
421        assert_eq!("ab", unquote(r#""ab""#));
422    }
423
424    #[test]
425    fn unquote_ignores_embedded_quotes() {
426        assert_eq!("a'b", unquote("'a'b'"));
427        assert_eq!(r#"a"b"#, unquote(r#"'a"b'"#));
428        assert_eq!(r#"a"b"#, unquote(r#""a"b""#));
429        assert_eq!(r#"a'b"#, unquote(r#""a'b""#));
430    }
431
432    #[test]
433    fn unquote_ignores_partial_quotes() {
434        assert_eq!("'", unquote("'"));
435        assert_eq!("'ab", unquote("'ab"));
436        assert_eq!("ab'", unquote("ab'"));
437        assert_eq!("'ab'ab", unquote("'ab'ab"));
438        assert_eq!("ab'ab'", unquote("ab'ab'"));
439        assert_eq!(r#"""#, unquote(r#"""#));
440        assert_eq!(r#""ab"#, unquote(r#""ab"#));
441        assert_eq!(r#"ab""#, unquote(r#"ab""#));
442        assert_eq!(r#""ab"ab"#, unquote(r#""ab"ab"#));
443        assert_eq!(r#"ab"ab""#, unquote(r#"ab"ab""#));
444    }
445
446    #[test]
447    fn unquote_ignores_mismatched_quotes() {
448        assert_eq!(r#""'"#, unquote(r#""'"#));
449        assert_eq!(r#"'""#, unquote(r#"'""#));
450        assert_eq!(r#""a'"#, unquote(r#""a'"#));
451        assert_eq!(r#"'a""#, unquote(r#"'a""#));
452        assert_eq!(r#""ab'"#, unquote(r#""ab'"#));
453        assert_eq!(r#"'ab""#, unquote(r#"'ab""#));
454    }
455
456    #[test]
457    fn find_units_in_empty_dir_finds_nothing() {
458        let pkg_unit_files = find_units(Path::new(""), "mypkg", None);
459        assert!(pkg_unit_files.is_empty());
460    }
461
462    fn assert_eq_found_unit(pkg_unit_files: &PackageUnitFiles, expected_install_path: &str, source_path: &str) {
463        let expected = InstallRecipe {
464            path: PathBuf::from(expected_install_path),
465            mode: 0o644,
466        };
467        let actual = pkg_unit_files.get(&PathBuf::from(source_path)).unwrap();
468        assert_eq!(&expected, actual);
469    }
470
471    #[test]
472    fn find_units_for_package() {
473        // one of each valid pattern (without a specific unit) and one
474        // additional valid pattern with a unit (which should not be matched
475        // as we don't specify a specific unit name to match)
476        let _g = add_test_fs_paths(&[
477            "debian/mypkg.mount",
478            "debian/mypkg@.path",
479            "debian/service", // demonstrates the main package fallback
480            "debian/mypkg@.socket",
481            "debian/mypkg.target",
482            "debian/mypkg@.timer",
483            "debian/mypkg.tmpfile",
484            "debian/mypkg.myunit.service", // demonstrates lack of unit name
485        ]);
486        let pkg_unit_files = find_units(Path::new("debian"), "mypkg", None);
487        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.mount",   "debian/mypkg.mount");
488        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.path",   "debian/mypkg@.path");
489        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.service", "debian/service");
490        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.socket", "debian/mypkg@.socket");
491        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.target",  "debian/mypkg.target");
492        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.timer",  "debian/mypkg@.timer");
493        assert_eq_found_unit(&pkg_unit_files, "usr/lib/tmpfiles.d/mypkg.conf",    "debian/mypkg.tmpfile");
494        assert_eq!(7, pkg_unit_files.len());
495    }
496
497    #[test]
498    fn find_named_units_for_package() {
499        // one of each valid pattern (with a specific unit) and one additional
500        // valid pattern without a unit (which should not be matched if there is
501        // match with the correctly named unit).
502        let _g = add_test_fs_paths(&[
503            "debian/mypkg.myunit.mount",
504            "debian/mypkg@.myunit.path",
505            "debian/service", // main package match should be ignored
506            "debian/mypkg@.myunit.socket",
507            "debian/target", // no unit or package but should be matched as fallback
508            "debian/mypkg@.myunit.timer",
509            "debian/mypkg.tmpfile", // no unit but should be matched as fallback
510            "debian/mypkg.myunit.service", // should be matched over main package match above
511        ]);
512
513        // add some paths that should not be matched
514        let _g = add_test_fs_paths(&[
515            "debian/nested/dir/mykpg.myunit.mount",
516            "debian/README.md",
517            "mypkg.myunit.mount",
518            "mypkg.mount",
519            "mount",
520            "postinit",
521            "mypkg.postinit",
522            "mypkg.myunit.postinit",
523        ]);
524
525        let pkg_unit_files = find_units(Path::new("debian"), "mypkg", Some("myunit"));
526        // note the "myunit" target names, even when the match was less specific
527        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.mount",   "debian/mypkg.myunit.mount");
528        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.path",   "debian/mypkg@.myunit.path");
529        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.service", "debian/mypkg.myunit.service");
530        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.socket", "debian/mypkg@.myunit.socket");
531        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.target",  "debian/target");
532        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.timer",  "debian/mypkg@.myunit.timer");
533
534        // note the changed file extension
535        assert_eq_found_unit(&pkg_unit_files, "usr/lib/tmpfiles.d/myunit.conf",    "debian/mypkg.tmpfile");
536
537        assert_eq!(7, pkg_unit_files.len());
538    }
539
540    #[test]
541    fn generate_with_empty_inputs_does_nothing() {
542        let mut mock_listener = crate::listener::MockListener::new();
543        mock_listener.expect_info().times(0).return_const(());
544
545        let fragments = generate("", &[], &Options::default(), &mock_listener).unwrap();
546
547        assert!(fragments.is_empty());
548    }
549
550    #[test]
551    fn generate_with_arbitrary_asset_does_nothing() {
552        let mut mock_listener = crate::listener::MockListener::new();
553        mock_listener.expect_info().times(0).return_const(());
554
555        let assets = vec![Asset::new(
556            AssetSource::Path(PathBuf::new()),
557            PathBuf::new(),
558            0o0,
559            IsBuilt::No,
560            AssetKind::Any,
561        )];
562
563        let fragments = generate("mypkg", &assets, &Options::default(), &mock_listener).unwrap();
564        assert!(fragments.is_empty());
565    }
566
567    #[test]
568    fn generate_with_invalid_tmp_file_asset_fails() {
569        let mut mock_listener = crate::listener::MockListener::new();
570        mock_listener.expect_info().times(0).return_const(());
571
572        let assets = vec![Asset::new(
573            AssetSource::Path(PathBuf::new()), // path source with empty source path makes no sense
574            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
575            0o0,
576            IsBuilt::No,
577            AssetKind::Any,
578        )];
579
580        assert!(generate("mypkg", &assets, &Options::default(), &mock_listener).is_err());
581    }
582
583    #[test]
584    fn generate_with_data_tmp_file_asset_fails() {
585        let mut mock_listener = crate::listener::MockListener::new();
586        mock_listener.expect_info().times(0).return_const(());
587
588        let assets = vec![Asset::new(
589            AssetSource::Data(vec![]), // only assets of type Path are currently supported
590            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
591            0o0,
592            IsBuilt::No,
593            AssetKind::Any,
594        )];
595
596        assert!(generate("mypkg", &assets, &Options::default(), &mock_listener).is_err());
597    }
598
599    #[test]
600    fn generate_with_empty_tmp_file_asset() {
601        use crate::dh::dh_lib::get_embedded_autoscript;
602
603        const TMP_FILE_NAME: &str = "my_tmp_file.tmpfile";
604        let tmp_file_path = PathBuf::from(format!("debian/{TMP_FILE_NAME}"));
605
606        let mut mock_listener = crate::listener::MockListener::new();
607        mock_listener.expect_progress().times(1).return_const(());
608
609        let assets = vec![Asset::new(
610            AssetSource::Path(tmp_file_path),
611            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
612            0o0,
613            IsBuilt::No,
614            AssetKind::Any,
615        )];
616
617        let fragments = generate("mypkg", &assets, &Options::default(), &mock_listener).unwrap();
618        assert_eq!(1, fragments.len());
619
620        let (fragment_name, created_text) = fragments.into_iter().next().unwrap();
621
622        // should create an augmentation for the postinst script
623        assert_eq!("mypkg.postinst.debhelper", fragment_name);
624
625        // Verify the created script contents. It should have two lines
626        // more than the autoscript fragment it was based on, like so:
627        //   # Automatically added by ...
628        //   <autoscript fragment lines with placeholders replaced>
629        //   # End automatically added section
630        let autoscript_text = get_embedded_autoscript("postinst-init-tmpfiles");
631        let autoscript_line_count = autoscript_text.lines().count();
632        let created_line_count = created_text.lines().count();
633        assert_eq!(autoscript_line_count + 2, created_line_count);
634
635        // Verify the content of the added comment lines
636        let mut lines = created_text.lines();
637        assert!(lines.next().unwrap().starts_with("# Automatically added by"));
638        assert_eq!(lines.nth_back(0).unwrap(), "# End automatically added section");
639
640        // Check that the autoscript fragment lines were properly copied
641        // into the created script complete with expected substitutions
642        let expected_autoscript_text = autoscript_text.replace("#TMPFILES#", TMP_FILE_NAME.replace(".tmpfile", ".conf").as_str());
643        let expected_autoscript_text = expected_autoscript_text.trim_end();
644        let start1 = 1;
645        let end1 = start1 + autoscript_line_count;
646        let created_autoscript_text = created_text.lines().collect::<Vec<&str>>()[start1..end1].join("\n");
647        assert_ne!(expected_autoscript_text, autoscript_text);
648        assert_eq!(expected_autoscript_text, created_autoscript_text);
649    }
650
651    #[test]
652    fn generate_filters_out_template_units() {
653        // "A template unit must have a single "@" at the end of the name
654        // (right before the type suffix)" - from:
655        //   https://www.freedesktop.org/software/systemd/man/systemd.unit.html
656        let mut mock_listener = crate::listener::MockListener::new();
657        mock_listener.expect_info().times(0).return_const(());
658
659        let assets = vec![Asset::new(
660            AssetSource::Path(PathBuf::from("debian/my_unit@.service")),
661            Path::new("usr/lib/systemd/system/").to_path_buf(),
662            0o0,
663            IsBuilt::No,
664            AssetKind::Any,
665        )];
666
667        let fragments = generate("mypkg", &assets, &Options::default(), &mock_listener).unwrap();
668        assert_eq!(0, fragments.len());
669    }
670
671    #[test]
672    fn generate_filters_out_subdir() {
673        let mut mock_listener = crate::listener::MockListener::new();
674        mock_listener.expect_info().times(0).return_const(());
675
676        let assets = vec![Asset::new(
677            AssetSource::Path(PathBuf::from("debian/10-extra-hardening.conf")),
678            Path::new("usr/lib/systemd/system/foobar.service.d/").to_path_buf(),
679            0o0,
680            IsBuilt::No,
681            AssetKind::Any,
682        )];
683
684        let fragments = generate("mypkg", &assets, &Options::default(), &mock_listener).unwrap();
685        assert_eq!(0, fragments.len());
686    }
687
688    #[test]
689    fn generate_acts_only_on_unit_files_with_the_expected_install_path() {
690        // Note: find_units() will set the target path correctly.
691        let mut mock_listener = crate::listener::MockListener::new();
692        mock_listener.expect_info().times(0).return_const(());
693
694        let assets = vec![Asset::new(
695            AssetSource::Path(PathBuf::from("debian/my_unit.service")),
696            Path::new("some/other/path/").to_path_buf(),
697            0o0,
698            IsBuilt::No,
699            AssetKind::Any,
700        )];
701
702        let fragments = generate("mypkg", &assets, &Options::default(), &mock_listener).unwrap();
703        assert_eq!(0, fragments.len());
704    }
705
706    #[rstest(ip, inst, ne, rau, ns, nsou,
707      case("ult", false, false, false, false, false),
708
709      case("lss", false, false, false, false, false),
710      case("lss", false, false, false, false, true),
711      case("lss", false, false, false, true,  false),
712      case("lss", false, false, false, true,  true),
713      case("lss", false, false, true,  false, false),
714      case("lss", false, false, true,  false,  true),
715      case("lss", false, false, true,  true,  false),
716      case("lss", false, false, true,  true,  true),
717      case("lss", false, true,  false, false, false),
718      case("lss", false, true,  false, false, true),
719      case("lss", false, true,  false, true,  false),
720      case("lss", false, true,  false, true,  true),
721      case("lss", false, true,  true,  false, false),
722      case("lss", false, true,  true,  false,  true),
723      case("lss", false, true,  true,  true,  false),
724      case("lss", false, true,  true,  true,  true),
725      case("lss", true,  false, false, false, false),
726      case("lss", true,  false, false, false, true),
727      case("lss", true,  false, false, true,  false),
728      case("lss", true,  false, false, true,  true),
729      case("lss", true,  false, true,  false, false),
730      case("lss", true,  false, true,  false,  true),
731      case("lss", true,  false, true,  true,  false),
732      case("lss", true,  false, true,  true,  true),
733      case("lss", true,  true,  false, false, false),
734      case("lss", true,  true,  false, false, true),
735      case("lss", true,  true,  false, true,  false),
736      case("lss", true,  true,  false, true,  true),
737      case("lss", true,  true,  true,  false, false),
738      case("lss", true,  true,  true,  false,  true),
739      case("lss", true,  true,  true,  true,  false),
740      case("lss", true,  true,  true,  true,  true),
741    )]
742    #[test]
743    fn generate_creates_expected_autoscript_fragments(
744        ip: &str,
745        inst: bool,
746        ne: bool,
747        rau: bool,
748        ns: bool,
749        nsou: bool,
750    ) {
751        let unit_file_path = "debian/mypkg.service";
752
753        let install_base_path = match ip {
754            "ult" => "usr/lib/tmpfiles.d",
755            "lss" => "usr/lib/systemd/system",
756            x => panic!("Unsupported install path value '{x}'"),
757        };
758
759        // setup input for generate()
760        let assets = vec![Asset::new(
761            AssetSource::Path(PathBuf::from(unit_file_path)),
762            format!("{install_base_path}/mypkg.service").into(),
763            0o0,
764            IsBuilt::No,
765            AssetKind::Any,
766        )];
767
768        let options = Options {
769            no_enable: ne,
770            no_start: ns,
771            restart_after_upgrade: rau,
772            no_stop_on_upgrade: nsou,
773        };
774
775        // setup mocks
776        let mut mock_listener = crate::listener::MockListener::new();
777        mock_listener.expect_progress().return_const(());
778
779        // start_units: yes
780        // enable_units: no, no [Install] section in the unit file
781
782        let mut unit_file_content = "[Unit]
783Description=A test unit
784
785[Service]
786Type=simple
787".to_owned();
788
789        if inst {
790            unit_file_content.push_str("[Install]
791WantedBy=multi-user.target");
792        }
793
794        set_test_fs_path_content(unit_file_path, unit_file_content);
795
796        // Add all Autoscript paths to the in-memory test file system so that
797        // we can track whether they are read or not.
798        let _g = add_test_fs_paths(&[
799            "postinst-init-tmpfiles",
800            "postinst-systemd-dont-enable",
801            "postinst-systemd-enable",
802            "postinst-systemd-restart",
803            "postinst-systemd-restartnostart",
804            "postinst-systemd-start",
805            "postrm-systemd",
806            "postrm-systemd-reload-only",
807            "prerm-systemd",
808            "prerm-systemd-restart",
809        ]);
810
811        // generate!
812        let fragments = generate("mypkg", &assets, &options, &mock_listener).unwrap();
813
814        // verify, though don't verify creation of autoscript fragments as that
815        // is verified in tests of the lower level functionality, instead verify
816        // only that the generate() logic creates the expected named fragments
817        // and while doing so read the expected autoscript files the expected
818        // number of times.
819
820        // Perl dh_installsystemd logic selects autoscript fragments based on
821        // the following conditions. If multiple columns have entries then all
822        // must be true. If a column has no value it is always true for all
823        // units.
824        //
825        // key:
826        //   - ip    - install path
827        //     - lss - lib/systemd/system/
828        //     - ult - usr/lib/tmpfiles.d/
829        //   - [I]   - has an [Install] section in the unit file
830        //   - ne    - the value of the boolean no_enable option
831        //   - rau   - the value of the boolean restart_after_upgrade option
832        //   - ns    - the value of the boolean no_start option
833        //   - nsou  - the value of the boolean no_stop_on_upgrade option
834        //   - /     - true/present (/* denotes one true is enough)
835        //   - x     - false/missing
836        //   - tr    - try_restart (value of #RESTART_ACTION# placeholder)
837        //   - r     - restart (value of #RESTART_ACTION# placeholder)
838        //
839        // -----------------------------------------------------------------------
840        // autoscript fragment             | ip  | [I] | ne | rau    | ns | nsou |
841        // -----------------------------------------------------------------------
842        // postinst-init-tmpfiles          | ult |     |    |        |    |      |
843        // postinst-systemd-dont-enable    | lss | /   | /  |        |    |      |
844        // postinst-systemd-enable         | lss | /   | x  |        |    |      |
845        // postinst-systemd-restart        | lss |     |    | / (tr) | x  |      |
846        // postinst-systemd-restartnostart | lss |     |    | / (r)  | /  |      |
847        // postinst-systemd-start          | lss |     |    | x      | x  |      |
848        // postrm-systemd                  | lss | /   |    |        |    |      |
849        // postrm-systemd-reload-only      | lss |     |    |        |    |      |
850        // prerm-systemd                   | lss |     |    | x      | x  | x    |
851        // prerm-systemd-restart           | lss |     |    | /*     |    | /*   |
852        // -----------------------------------------------------------------------
853
854        let mut autoscript_fragments_to_check_for = std::collections::HashSet::new();
855
856        match ip {
857            "ult" => {
858                assert_eq!(1, get_read_count("postinst-init-tmpfiles"));
859                autoscript_fragments_to_check_for.insert("postinst.debhelper");
860            },
861            "lss" => {
862                assert_eq!(1, get_read_count(unit_file_path));
863                if inst {
864                    if options.no_enable {
865                        assert_eq!(1, get_read_count("postinst-systemd-dont-enable"));
866                    } else {
867                        assert_eq!(1, get_read_count("postinst-systemd-enable"));
868                    }
869                    assert_eq!(1, get_read_count("postrm-systemd"));
870                    autoscript_fragments_to_check_for.insert("postinst.service");
871                    autoscript_fragments_to_check_for.insert("postrm.debhelper");
872                }
873                if options.restart_after_upgrade {
874                    if options.no_start {
875                        assert_eq!(1, get_read_count("postinst-systemd-restartnostart"));
876                    } else {
877                        assert_eq!(1, get_read_count("postinst-systemd-restart"));
878                    }
879                    autoscript_fragments_to_check_for.insert("postinst.service");
880                } else if !options.no_start {
881                    assert_eq!(1, get_read_count("postinst-systemd-start"));
882                    autoscript_fragments_to_check_for.insert("postinst.service");
883                }
884                if options.restart_after_upgrade || options.no_stop_on_upgrade {
885                    assert_eq!(1, get_read_count("prerm-systemd-restart"));
886                    autoscript_fragments_to_check_for.insert("prerm.service");
887                } else if !options.no_start {
888                    assert_eq!(1, get_read_count("prerm-systemd"));
889                    autoscript_fragments_to_check_for.insert("prerm.service");
890                }
891                assert_eq!(1, get_read_count("postrm-systemd-reload-only"));
892                autoscript_fragments_to_check_for.insert("postrm.debhelper");
893            },
894            _ => unreachable!(),
895        }
896
897        for autoscript in &autoscript_fragments_to_check_for {
898            let key = format!("mypkg.{autoscript}");
899            assert!(fragments.contains_key(&key), "{}", key);
900        }
901    }
902}