Skip to main content

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/// Determine if the given file name (e.g. "foo.service") is a unit file of the named systemd unit (e.g. "foo"),
182/// i.e. whether the file name without its unit type extension matches the unit name.
183///
184/// See [`Options`]'s explanation of `--name`.
185fn is_unit_of(fname: &str, unit_name: &str) -> bool {
186    fname.rsplit_once('.').is_some_and(|(stem, _)| stem == unit_name)
187}
188
189/// Strip off any first layer of outer quotes according to systemd quoting
190/// rules.
191///
192/// See:
193///   <https://www.freedesktop.org/software/systemd/man/systemd.service.html#Command%20lines>
194fn unquote(s: &str) -> &str {
195    if s.len() > 1 &&
196       ((s.starts_with('"') && s.ends_with('"')) ||
197       (s.starts_with('\'') && s.ends_with('\''))) {
198        &s[1..s.len()-1]
199    } else {
200        s
201    }
202}
203
204/// This function implements the primary logic of the Debian `dh_installsystemd`
205/// Perl script, which is to say it identifies systemd units being installed,
206/// inspects them and decides, based on the unit file and the configuration
207/// options provided, which `DebHelper` autoscripts to use to correctly install
208/// those units.
209///
210/// # Cargo Deb specific behaviour
211///
212/// Any `Asset`, whether identified by `find_units()` or added by the user
213/// manually in Cargo.toml, that will be installed into `LIB_SYSTEMD_SYSTEM_DIR`
214/// will be analysed.
215///
216/// When `unit_name` is provided, we only act on unit files whose file name (stripped of the unit type extension) matches.
217/// This allows per `systemd-units` entry settings.
218///
219/// Unlike `dh_installsystemd` results are accumulated into the given `ScriptFragments` value
220/// rather than being written to temporary files on disk.
221///
222/// Repeated calls (e.g. one per `systemd-units` entry) add to the existing fragments.
223///
224/// # Usage
225///
226/// Pass the accumulated `ScriptFragments` to `apply()`.
227///
228/// See:
229///   <https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n288>
230pub fn generate(package: &str, assets: &[Asset], unit_name: Option<&str>, options: &Options, scripts: &mut ScriptFragments, listener: &dyn Listener) -> CDResult<()> {
231    // add postinst code blocks to handle tmpfiles
232    // see: <https://salsa.debian.org/debian/debhelper/-/blob/master/dh_installsystemd#L305>
233    // tmpfiles are installed with .conf as extension
234    // <https://www.freedesktop.org/software/systemd/man/tmpfiles.d.html>
235    let tmp_file_names = assets
236        .iter()
237        .filter(|a| a.c.target_path.starts_with(USR_LIB_TMPFILES_D_DIR))
238        .filter(|a| match unit_name {
239            Some(unit_name) => fname_from_path(a.c.target_path.as_path())
240                .is_some_and(|fname| fname == unit_name || is_unit_of(&fname, unit_name)),
241            None => true,
242        })
243        .map(|v| {
244            v.source.source_path()
245                .and_then(|p| fname_from_path(&p.with_extension("conf")))
246                .ok_or(CargoDebError::Str("dh_installsystemd: invalid source path"))
247        })
248        .collect::<CDResult<Vec<String>>>()?
249        .join(" ");
250
251    if !tmp_file_names.is_empty() {
252        autoscript(scripts, package, "postinst", "postinst-init-tmpfiles",
253            &map!{ "TMPFILES" => tmp_file_names }, false, listener)?;
254    }
255
256    // add postinst, prerm, and postrm code blocks to handle activation,
257    // deactivation, start and stopping of services when the package is
258    // installed, upgraded or removed.
259    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n312
260
261    // skip template service files. Enabling, disabling, starting or stopping
262    // those services without specifying the instance is not useful.
263    let mut installed_non_template_units: BTreeSet<String> = BTreeSet::new();
264    installed_non_template_units.extend(
265        assets
266            .iter()
267            .filter(|a| a.c.target_path.parent() == Some(LIB_SYSTEMD_SYSTEM_DIR.as_ref()))
268            .filter_map(|a| fname_from_path(a.c.target_path.as_path()))
269            .filter(|fname| !fname.contains('@'))
270            .filter(|fname| match unit_name {
271                Some(unit_name) => is_unit_of(fname, unit_name),
272                None => true,
273            }),
274    );
275
276    // BTreeSets values iterate in sorted order irrespective of the order they
277    // were inserted.
278    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n385
279    let mut enable_units = BTreeSet::new();
280    let mut start_units = BTreeSet::new();
281    let mut seen = BTreeSet::new();
282
283    // note: we do not support handling of services with a sysv-equivalent
284    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n373
285    let mut units = installed_non_template_units;
286
287    // for all installed non-template units and any units they refer to via
288    // the 'Also=' key in their unit file, determine what if anything we need to
289    // arrange to be done for them in the maintainer scripts.
290    while !units.is_empty() {
291        // gather unit names mentioned in 'Also=' kv pairs in the unit files
292        let mut also_units = BTreeSet::<String>::new();
293
294        // for each unit that we have not yet processed
295        for unit in &units {
296            listener.progress("Checking", format!("augmentations needed for systemd unit {unit}"));
297
298            // the unit has to be started
299            start_units.insert(unit.clone());
300
301            // get the unit file contents
302            let needle = Path::new(LIB_SYSTEMD_SYSTEM_DIR).join(unit);
303            let data = assets.iter().find(move |&item| item.c.target_path == needle).unwrap().source.data()?;
304            let reader = data.into_owned();
305
306            // for every line in the file look for specific keys that we are
307            // interested in:
308            // From: https://www.freedesktop.org/software/systemd/man/systemd.syntax.html
309            //   "Each file is a plain text file divided into sections, with
310            //    configuration entries in the style key=value. Whitespace
311            //    immediately before or after the "=" is ignored. Empty lines
312            //    and lines starting with "#" or ";" are ignored which may be
313            //    used for commenting."
314            //   "Various settings are allowed to be specified more than
315            //    once"
316            // Key names _seem_ to be case sensitive. It's not explicitly
317            // stated in systemd.syntax.html above but this bug report seems
318            // to confirm it:
319            //   https://bugzilla.redhat.com/show_bug.cgi?id=846283
320            // We also strip the value of any surrounding quotes because
321            // that's what the actual dh_installsystemd code does:
322            //   https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n210
323            for line in reader.lines().map(|line| line.unwrap()).filter(|s| !is_comment(s)) {
324                let possible_kv_pair = line.splitn(2, '=').map(|s| s.trim()).next_tuple();
325                if let Some((key, value)) = possible_kv_pair {
326                    let other_unit = unquote(value).to_string();
327                    match key {
328                        "Also" => {
329                            // The seen lookup prevents us from looping forever over
330                            // unit files that refer to each other. An actual
331                            // real-world example of such a loop is systemd's
332                            // systemd-readahead-drop.service, which contains
333                            // Also=systemd-readahead-collect.service, and that file
334                            // in turn contains Also=systemd-readahead-drop.service,
335                            // thus forming an endless loop.
336                            // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n340
337                            if seen.insert(other_unit.clone()) {
338                                also_units.insert(other_unit);
339                            }
340                        },
341                        "Alias" => {
342                            // TODO?
343                        },
344                        _ => (),
345                    }
346                } else if line.starts_with("[Install]") {
347                    enable_units.insert(unit.clone());
348                }
349            }
350        }
351        units = also_units;
352    }
353
354    // update the maintainer scripts to enable units unless forbidden by the
355    // options passed to us.
356    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n390
357    if !enable_units.is_empty() {
358        let snippet = if options.no_enable { "postinst-systemd-dont-enable" } else { "postinst-systemd-enable" };
359        for unit in &enable_units {
360            autoscript(scripts, package, "postinst", snippet,
361                &map!{ "UNITFILE" => unit.clone() }, true, listener)?;
362        }
363        autoscript(scripts, package, "postrm", "postrm-systemd",
364            &map!{ "UNITFILES" => enable_units.join(" ") }, false, listener)?;
365    }
366
367    // update the maintainer scripts to start units, where the exact action to
368    // be taken is influenced by the options passed to us.
369    // see: https://git.launchpad.net/ubuntu/+source/debhelper/tree/dh_installsystemd?h=applied/12.10ubuntu1#n398
370    if !start_units.is_empty() {
371        let mut replace = map! { "UNITFILES" => start_units.join(" ") };
372
373        if options.restart_after_upgrade {
374            let snippet = if options.no_start {
375                replace.insert("RESTART_ACTION", "try-restart".into());
376                "postinst-systemd-restartnostart"
377            } else {
378                replace.insert("RESTART_ACTION", "restart".into());
379                "postinst-systemd-restart"
380            };
381            autoscript(scripts, package, "postinst", snippet, &replace, true, listener)?;
382        } else if !options.no_start {
383            // (stop|start) service (before|after) upgrade
384            autoscript(scripts, package, "postinst", "postinst-systemd-start", &replace, true, listener)?;
385        }
386
387        if options.no_stop_on_upgrade || options.restart_after_upgrade {
388            // stop service only on remove
389            autoscript(scripts, package, "prerm", "prerm-systemd-restart", &replace, true, listener)?;
390        } else if !options.no_start {
391            // always stop service
392            autoscript(scripts, package, "prerm", "prerm-systemd", &replace, true, listener)?;
393        }
394
395        // Run this with "default" order so it is always after other service
396        // related autosnippets.
397        autoscript(scripts, package, "postrm", "postrm-systemd-reload-only", &replace, false, listener)?;
398    }
399
400    Ok(())
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::assets::{Asset, AssetKind, AssetSource, IsBuilt};
407    use crate::util::tests::{add_test_fs_paths, get_read_count, set_test_fs_path_content};
408    use rstest::*;
409
410    #[test]
411    fn is_comment_detects_comments() {
412        assert!(is_comment("#"));
413        assert!(is_comment("#  "));
414        assert!(is_comment("# some comment"));
415        assert!(is_comment(";"));
416        assert!(is_comment(";  "));
417        assert!(is_comment("; some comment"));
418    }
419
420    #[test]
421    fn is_comment_detects_non_comments() {
422        assert!(!is_comment(" #"));
423        assert!(!is_comment(" #  "));
424        assert!(!is_comment(" # some comment"));
425        assert!(!is_comment(" ;"));
426        assert!(!is_comment(" ;  "));
427        assert!(!is_comment(" ; some comment"));
428    }
429
430    #[test]
431    fn unquote_unquotes_matching_single_quotes() {
432        assert_eq!("", unquote("''"));
433        assert_eq!("a", unquote("'a'"));
434        assert_eq!("ab", unquote("'ab'"));
435    }
436
437    #[test]
438    fn unquote_unquotes_matching_double_quotes() {
439        assert_eq!("", unquote(r#""""#));
440        assert_eq!("a", unquote(r#""a""#));
441        assert_eq!("ab", unquote(r#""ab""#));
442    }
443
444    #[test]
445    fn unquote_ignores_embedded_quotes() {
446        assert_eq!("a'b", unquote("'a'b'"));
447        assert_eq!(r#"a"b"#, unquote(r#"'a"b'"#));
448        assert_eq!(r#"a"b"#, unquote(r#""a"b""#));
449        assert_eq!(r"a'b", unquote(r#""a'b""#));
450    }
451
452    #[test]
453    fn unquote_ignores_partial_quotes() {
454        assert_eq!("'", unquote("'"));
455        assert_eq!("'ab", unquote("'ab"));
456        assert_eq!("ab'", unquote("ab'"));
457        assert_eq!("'ab'ab", unquote("'ab'ab"));
458        assert_eq!("ab'ab'", unquote("ab'ab'"));
459        assert_eq!(r#"""#, unquote(r#"""#));
460        assert_eq!(r#""ab"#, unquote(r#""ab"#));
461        assert_eq!(r#"ab""#, unquote(r#"ab""#));
462        assert_eq!(r#""ab"ab"#, unquote(r#""ab"ab"#));
463        assert_eq!(r#"ab"ab""#, unquote(r#"ab"ab""#));
464    }
465
466    #[test]
467    fn unquote_ignores_mismatched_quotes() {
468        assert_eq!(r#""'"#, unquote(r#""'"#));
469        assert_eq!(r#"'""#, unquote(r#"'""#));
470        assert_eq!(r#""a'"#, unquote(r#""a'"#));
471        assert_eq!(r#"'a""#, unquote(r#"'a""#));
472        assert_eq!(r#""ab'"#, unquote(r#""ab'"#));
473        assert_eq!(r#"'ab""#, unquote(r#"'ab""#));
474    }
475
476    #[test]
477    fn find_units_in_empty_dir_finds_nothing() {
478        let pkg_unit_files = find_units(Path::new(""), "mypkg", None);
479        assert!(pkg_unit_files.is_empty());
480    }
481
482    fn assert_eq_found_unit(pkg_unit_files: &PackageUnitFiles, expected_install_path: &str, source_path: &str) {
483        let expected = InstallRecipe {
484            path: PathBuf::from(expected_install_path),
485            mode: 0o644,
486        };
487        let actual = pkg_unit_files.get(&PathBuf::from(source_path)).unwrap();
488        assert_eq!(&expected, actual);
489    }
490
491    #[test]
492    fn find_units_for_package() {
493        // one of each valid pattern (without a specific unit) and one
494        // additional valid pattern with a unit (which should not be matched
495        // as we don't specify a specific unit name to match)
496        let _g = add_test_fs_paths(&[
497            "debian/mypkg.mount",
498            "debian/mypkg@.path",
499            "debian/service", // demonstrates the main package fallback
500            "debian/mypkg@.socket",
501            "debian/mypkg.target",
502            "debian/mypkg@.timer",
503            "debian/mypkg.tmpfile",
504            "debian/mypkg.myunit.service", // demonstrates lack of unit name
505        ]);
506        let pkg_unit_files = find_units(Path::new("debian"), "mypkg", None);
507        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.mount",   "debian/mypkg.mount");
508        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.path",   "debian/mypkg@.path");
509        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.service", "debian/service");
510        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.socket", "debian/mypkg@.socket");
511        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg.target",  "debian/mypkg.target");
512        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/mypkg@.timer",  "debian/mypkg@.timer");
513        assert_eq_found_unit(&pkg_unit_files, "usr/lib/tmpfiles.d/mypkg.conf",    "debian/mypkg.tmpfile");
514        assert_eq!(7, pkg_unit_files.len());
515    }
516
517    #[test]
518    fn find_named_units_for_package() {
519        // one of each valid pattern (with a specific unit) and one additional
520        // valid pattern without a unit (which should not be matched if there is
521        // match with the correctly named unit).
522        let _g = add_test_fs_paths(&[
523            "debian/mypkg.myunit.mount",
524            "debian/mypkg@.myunit.path",
525            "debian/service", // main package match should be ignored
526            "debian/mypkg@.myunit.socket",
527            "debian/target", // no unit or package but should be matched as fallback
528            "debian/mypkg@.myunit.timer",
529            "debian/mypkg.tmpfile", // no unit but should be matched as fallback
530            "debian/mypkg.myunit.service", // should be matched over main package match above
531        ]);
532
533        // add some paths that should not be matched
534        let _g = add_test_fs_paths(&[
535            "debian/nested/dir/mykpg.myunit.mount",
536            "debian/README.md",
537            "mypkg.myunit.mount",
538            "mypkg.mount",
539            "mount",
540            "postinit",
541            "mypkg.postinit",
542            "mypkg.myunit.postinit",
543        ]);
544
545        let pkg_unit_files = find_units(Path::new("debian"), "mypkg", Some("myunit"));
546        // note the "myunit" target names, even when the match was less specific
547        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.mount",   "debian/mypkg.myunit.mount");
548        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.path",   "debian/mypkg@.myunit.path");
549        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.service", "debian/mypkg.myunit.service");
550        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.socket", "debian/mypkg@.myunit.socket");
551        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit.target",  "debian/target");
552        assert_eq_found_unit(&pkg_unit_files, "usr/lib/systemd/system/myunit@.timer",  "debian/mypkg@.myunit.timer");
553
554        // note the changed file extension
555        assert_eq_found_unit(&pkg_unit_files, "usr/lib/tmpfiles.d/myunit.conf",    "debian/mypkg.tmpfile");
556
557        assert_eq!(7, pkg_unit_files.len());
558    }
559
560    #[test]
561    fn generate_with_empty_inputs_does_nothing() {
562        let mut mock_listener = crate::listener::MockListener::new();
563        mock_listener.expect_info().times(0).return_const(());
564
565        let mut fragments = ScriptFragments::new();
566        generate("", &[], None, &Options::default(), &mut fragments, &mock_listener).unwrap();
567
568        assert!(fragments.is_empty());
569    }
570
571    #[test]
572    fn generate_with_arbitrary_asset_does_nothing() {
573        let mut mock_listener = crate::listener::MockListener::new();
574        mock_listener.expect_info().times(0).return_const(());
575
576        let assets = vec![Asset::new(
577            AssetSource::Path(PathBuf::new()),
578            PathBuf::new(),
579            Some(0o0),
580            IsBuilt::No,
581            AssetKind::Any,
582        )];
583
584        let mut fragments = ScriptFragments::new();
585        generate("mypkg", &assets, None, &Options::default(), &mut fragments, &mock_listener).unwrap();
586        assert!(fragments.is_empty());
587    }
588
589    #[test]
590    fn generate_with_invalid_tmp_file_asset_fails() {
591        let mut mock_listener = crate::listener::MockListener::new();
592        mock_listener.expect_info().times(0).return_const(());
593
594        let assets = vec![Asset::new(
595            AssetSource::Path(PathBuf::new()), // path source with empty source path makes no sense
596            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
597            Some(0o0),
598            IsBuilt::No,
599            AssetKind::Any,
600        )];
601
602        assert!(generate("mypkg", &assets, None, &Options::default(), &mut ScriptFragments::new(), &mock_listener).is_err());
603    }
604
605    #[test]
606    fn generate_with_data_tmp_file_asset_fails() {
607        let mut mock_listener = crate::listener::MockListener::new();
608        mock_listener.expect_info().times(0).return_const(());
609
610        let assets = vec![Asset::new(
611            AssetSource::Data(vec![]), // only assets of type Path are currently supported
612            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
613            Some(0o0),
614            IsBuilt::No,
615            AssetKind::Any,
616        )];
617
618        assert!(generate("mypkg", &assets, None, &Options::default(), &mut ScriptFragments::new(), &mock_listener).is_err());
619    }
620
621    #[test]
622    fn generate_with_empty_tmp_file_asset() {
623        use crate::dh::dh_lib::get_embedded_autoscript;
624
625        const TMP_FILE_NAME: &str = "my_tmp_file.tmpfile";
626        let tmp_file_path = PathBuf::from(format!("debian/{TMP_FILE_NAME}"));
627
628        let mut mock_listener = crate::listener::MockListener::new();
629        mock_listener.expect_progress().times(1).return_const(());
630
631        let assets = vec![Asset::new(
632            AssetSource::Path(tmp_file_path),
633            Path::new("usr/lib/tmpfiles.d/blah").to_path_buf(),
634            Some(0o0),
635            IsBuilt::No,
636            AssetKind::Any,
637        )];
638
639        let mut fragments = ScriptFragments::new();
640        generate("mypkg", &assets, None, &Options::default(), &mut fragments, &mock_listener).unwrap();
641        assert_eq!(1, fragments.len());
642
643        let (fragment_name, created_text) = fragments.into_iter().next().unwrap();
644
645        // should create an augmentation for the postinst script
646        assert_eq!("mypkg.postinst.debhelper", fragment_name);
647
648        // Verify the created script contents. It should have two lines
649        // more than the autoscript fragment it was based on, like so:
650        //   # Automatically added by ...
651        //   <autoscript fragment lines with placeholders replaced>
652        //   # End automatically added section
653        let autoscript_text = get_embedded_autoscript("postinst-init-tmpfiles");
654        let autoscript_line_count = autoscript_text.lines().count();
655        let created_line_count = created_text.lines().count();
656        assert_eq!(autoscript_line_count + 2, created_line_count);
657
658        // Verify the content of the added comment lines
659        let mut lines = created_text.lines();
660        assert!(lines.next().unwrap().starts_with("# Automatically added by"));
661        assert_eq!(lines.nth_back(0).unwrap(), "# End automatically added section");
662
663        // Check that the autoscript fragment lines were properly copied
664        // into the created script complete with expected substitutions
665        let expected_autoscript_text = autoscript_text.replace("#TMPFILES#", TMP_FILE_NAME.replace(".tmpfile", ".conf").as_str());
666        let expected_autoscript_text = expected_autoscript_text.trim_end();
667        let start1 = 1;
668        let end1 = start1 + autoscript_line_count;
669        let created_autoscript_text = created_text.lines().collect::<Vec<&str>>()[start1..end1].join("\n");
670        assert_ne!(expected_autoscript_text, autoscript_text);
671        assert_eq!(expected_autoscript_text, created_autoscript_text);
672    }
673
674    #[test]
675    fn generate_filters_out_template_units() {
676        // "A template unit must have a single "@" at the end of the name
677        // (right before the type suffix)" - from:
678        //   https://www.freedesktop.org/software/systemd/man/systemd.unit.html
679        let mut mock_listener = crate::listener::MockListener::new();
680        mock_listener.expect_info().times(0).return_const(());
681
682        let assets = vec![Asset::new(
683            AssetSource::Path(PathBuf::from("debian/my_unit@.service")),
684            Path::new("usr/lib/systemd/system/").to_path_buf(),
685            Some(0o0),
686            IsBuilt::No,
687            AssetKind::Any,
688        )];
689
690        let mut fragments = ScriptFragments::new();
691        generate("mypkg", &assets, None, &Options::default(), &mut fragments, &mock_listener).unwrap();
692        assert_eq!(0, fragments.len());
693    }
694
695    #[test]
696    fn generate_filters_out_subdir() {
697        let mut mock_listener = crate::listener::MockListener::new();
698        mock_listener.expect_info().times(0).return_const(());
699
700        let assets = vec![Asset::new(
701            AssetSource::Path(PathBuf::from("debian/10-extra-hardening.conf")),
702            Path::new("usr/lib/systemd/system/foobar.service.d/").to_path_buf(),
703            Some(0o0),
704            IsBuilt::No,
705            AssetKind::Any,
706        )];
707
708        let mut fragments = ScriptFragments::new();
709        generate("mypkg", &assets, None, &Options::default(), &mut fragments, &mock_listener).unwrap();
710        assert_eq!(0, fragments.len());
711    }
712
713    #[test]
714    fn generate_acts_only_on_unit_files_with_the_expected_install_path() {
715        // Note: find_units() will set the target path correctly.
716        let mut mock_listener = crate::listener::MockListener::new();
717        mock_listener.expect_info().times(0).return_const(());
718
719        let assets = vec![Asset::new(
720            AssetSource::Path(PathBuf::from("debian/my_unit.service")),
721            Path::new("some/other/path/").to_path_buf(),
722            Some(0o0),
723            IsBuilt::No,
724            AssetKind::Any,
725        )];
726
727        let mut fragments = ScriptFragments::new();
728        generate("mypkg", &assets, None, &Options::default(), &mut fragments, &mock_listener).unwrap();
729        assert_eq!(0, fragments.len());
730    }
731
732    fn unit_asset(source: &'static str, target: &str) -> Asset {
733        let test_unit_file_content = "[Unit]
734Description=A test unit
735
736[Service]
737Type=simple".to_owned();
738
739        set_test_fs_path_content(source, test_unit_file_content);
740
741        Asset::new(
742            AssetSource::Path(PathBuf::from(source)),
743            Path::new(target).to_path_buf(),
744            Some(0o0),
745            IsBuilt::No,
746            AssetKind::Any,
747        )
748    }
749
750    #[test]
751    fn generate_scopes_actions_to_the_given_unit_name() {
752        let mut mock_listener = crate::listener::MockListener::new();
753        mock_listener.expect_progress().return_const(());
754
755        let _g = add_test_fs_paths(&[]);
756
757        let assets = vec![
758            unit_asset("debian/main.service", "usr/lib/systemd/system/main.service"),
759            unit_asset("debian/other.service", "usr/lib/systemd/system/other.service"),
760        ];
761
762        let mut fragments = ScriptFragments::new();
763        generate("mypkg", &assets, Some("other"), &Options::default(), &mut fragments, &mock_listener).unwrap();
764
765        let postinst = fragments.get("mypkg.postinst.service").unwrap();
766        assert!(postinst.contains("other.service"));
767        assert!(!postinst.contains("main.service"));
768    }
769
770    #[test]
771    fn generate_accumulates_fragments_with_per_entry_options() {
772        let mut mock_listener = crate::listener::MockListener::new();
773        mock_listener.expect_progress().return_const(());
774
775        let _g = add_test_fs_paths(&[]);
776
777        let assets = vec![
778            unit_asset("debian/main.service", "usr/lib/systemd/system/main.service"),
779            unit_asset("debian/other.service", "usr/lib/systemd/system/other.service"),
780        ];
781
782        let mut fragments = ScriptFragments::new();
783
784        let entries = [
785            // starts and restarts
786            (Some("main"), Options { restart_after_upgrade: true, ..Options::default() }),
787            // only restarts, should be running
788            (Some("other"), Options { restart_after_upgrade: true, no_start: true, ..Options::default() }),
789        ];
790
791        for (unit_name, options) in &entries {
792            generate("mypkg", &assets, *unit_name, options, &mut fragments, &mock_listener).unwrap();
793        }
794
795        let postinst = fragments.get("mypkg.postinst.service").unwrap();
796        assert!(postinst.contains("deb-systemd-invoke $_dh_action main.service"));
797        // only restart when other.service is already running
798        assert!(postinst.contains("deb-systemd-invoke try-restart other.service"));
799
800        assert!(!postinst.contains("try-restart main.service"));
801        assert!(!postinst.contains("$_dh_action other.service"));
802
803        // both stop
804        let prerm = fragments.get("mypkg.prerm.service").unwrap();
805        assert!(prerm.contains("deb-systemd-invoke stop main.service"));
806        assert!(prerm.contains("deb-systemd-invoke stop other.service"));
807
808        let postrm = fragments.get("mypkg.postrm.debhelper").unwrap();
809        assert_eq!(1, postrm.matches("daemon-reload").count());
810    }
811
812    #[rstest(ip, inst, ne, rau, ns, nsou,
813      case("ult", false, false, false, false, false),
814
815      case("lss", false, false, false, false, false),
816      case("lss", false, false, false, false, true),
817      case("lss", false, false, false, true,  false),
818      case("lss", false, false, false, true,  true),
819      case("lss", false, false, true,  false, false),
820      case("lss", false, false, true,  false,  true),
821      case("lss", false, false, true,  true,  false),
822      case("lss", false, false, true,  true,  true),
823      case("lss", false, true,  false, false, false),
824      case("lss", false, true,  false, false, true),
825      case("lss", false, true,  false, true,  false),
826      case("lss", false, true,  false, true,  true),
827      case("lss", false, true,  true,  false, false),
828      case("lss", false, true,  true,  false,  true),
829      case("lss", false, true,  true,  true,  false),
830      case("lss", false, true,  true,  true,  true),
831      case("lss", true,  false, false, false, false),
832      case("lss", true,  false, false, false, true),
833      case("lss", true,  false, false, true,  false),
834      case("lss", true,  false, false, true,  true),
835      case("lss", true,  false, true,  false, false),
836      case("lss", true,  false, true,  false,  true),
837      case("lss", true,  false, true,  true,  false),
838      case("lss", true,  false, true,  true,  true),
839      case("lss", true,  true,  false, false, false),
840      case("lss", true,  true,  false, false, true),
841      case("lss", true,  true,  false, true,  false),
842      case("lss", true,  true,  false, true,  true),
843      case("lss", true,  true,  true,  false, false),
844      case("lss", true,  true,  true,  false,  true),
845      case("lss", true,  true,  true,  true,  false),
846      case("lss", true,  true,  true,  true,  true),
847    )]
848    #[test]
849    fn generate_creates_expected_autoscript_fragments(
850        ip: &str,
851        inst: bool,
852        ne: bool,
853        rau: bool,
854        ns: bool,
855        nsou: bool,
856    ) {
857        let unit_file_path = "debian/mypkg.service";
858
859        let install_base_path = match ip {
860            "ult" => "usr/lib/tmpfiles.d",
861            "lss" => "usr/lib/systemd/system",
862            x => panic!("Unsupported install path value '{x}'"),
863        };
864
865        // setup input for generate()
866        let assets = vec![Asset::new(
867            AssetSource::Path(PathBuf::from(unit_file_path)),
868            format!("{install_base_path}/mypkg.service").into(),
869            Some(0o0),
870            IsBuilt::No,
871            AssetKind::Any,
872        )];
873
874        let options = Options {
875            no_enable: ne,
876            no_start: ns,
877            restart_after_upgrade: rau,
878            no_stop_on_upgrade: nsou,
879        };
880
881        // setup mocks
882        let mut mock_listener = crate::listener::MockListener::new();
883        mock_listener.expect_progress().return_const(());
884
885        // start_units: yes
886        // enable_units: no, no [Install] section in the unit file
887
888        let mut unit_file_content = "[Unit]
889Description=A test unit
890
891[Service]
892Type=simple
893".to_owned();
894
895        if inst {
896            unit_file_content.push_str("[Install]
897WantedBy=multi-user.target");
898        }
899
900        set_test_fs_path_content(unit_file_path, unit_file_content);
901
902        // Add all Autoscript paths to the in-memory test file system so that
903        // we can track whether they are read or not.
904        let _g = add_test_fs_paths(&[
905            "postinst-init-tmpfiles",
906            "postinst-systemd-dont-enable",
907            "postinst-systemd-enable",
908            "postinst-systemd-restart",
909            "postinst-systemd-restartnostart",
910            "postinst-systemd-start",
911            "postrm-systemd",
912            "postrm-systemd-reload-only",
913            "prerm-systemd",
914            "prerm-systemd-restart",
915        ]);
916
917        // generate!
918        let mut fragments = ScriptFragments::new();
919        generate("mypkg", &assets, None, &options, &mut fragments, &mock_listener).unwrap();
920
921        // verify, though don't verify creation of autoscript fragments as that
922        // is verified in tests of the lower level functionality, instead verify
923        // only that the generate() logic creates the expected named fragments
924        // and while doing so read the expected autoscript files the expected
925        // number of times.
926
927        // Perl dh_installsystemd logic selects autoscript fragments based on
928        // the following conditions. If multiple columns have entries then all
929        // must be true. If a column has no value it is always true for all
930        // units.
931        //
932        // key:
933        //   - ip    - install path
934        //     - lss - lib/systemd/system/
935        //     - ult - usr/lib/tmpfiles.d/
936        //   - [I]   - has an [Install] section in the unit file
937        //   - ne    - the value of the boolean no_enable option
938        //   - rau   - the value of the boolean restart_after_upgrade option
939        //   - ns    - the value of the boolean no_start option
940        //   - nsou  - the value of the boolean no_stop_on_upgrade option
941        //   - /     - true/present (/* denotes one true is enough)
942        //   - x     - false/missing
943        //   - tr    - try_restart (value of #RESTART_ACTION# placeholder)
944        //   - r     - restart (value of #RESTART_ACTION# placeholder)
945        //
946        // -----------------------------------------------------------------------
947        // autoscript fragment             | ip  | [I] | ne | rau    | ns | nsou |
948        // -----------------------------------------------------------------------
949        // postinst-init-tmpfiles          | ult |     |    |        |    |      |
950        // postinst-systemd-dont-enable    | lss | /   | /  |        |    |      |
951        // postinst-systemd-enable         | lss | /   | x  |        |    |      |
952        // postinst-systemd-restart        | lss |     |    | / (tr) | x  |      |
953        // postinst-systemd-restartnostart | lss |     |    | / (r)  | /  |      |
954        // postinst-systemd-start          | lss |     |    | x      | x  |      |
955        // postrm-systemd                  | lss | /   |    |        |    |      |
956        // postrm-systemd-reload-only      | lss |     |    |        |    |      |
957        // prerm-systemd                   | lss |     |    | x      | x  | x    |
958        // prerm-systemd-restart           | lss |     |    | /*     |    | /*   |
959        // -----------------------------------------------------------------------
960
961        let mut autoscript_fragments_to_check_for = std::collections::HashSet::new();
962
963        match ip {
964            "ult" => {
965                assert_eq!(1, get_read_count("postinst-init-tmpfiles"));
966                autoscript_fragments_to_check_for.insert("postinst.debhelper");
967            },
968            "lss" => {
969                assert_eq!(1, get_read_count(unit_file_path));
970                if inst {
971                    if options.no_enable {
972                        assert_eq!(1, get_read_count("postinst-systemd-dont-enable"));
973                    } else {
974                        assert_eq!(1, get_read_count("postinst-systemd-enable"));
975                    }
976                    assert_eq!(1, get_read_count("postrm-systemd"));
977                    autoscript_fragments_to_check_for.insert("postinst.service");
978                    autoscript_fragments_to_check_for.insert("postrm.debhelper");
979                }
980                if options.restart_after_upgrade {
981                    if options.no_start {
982                        assert_eq!(1, get_read_count("postinst-systemd-restartnostart"));
983                    } else {
984                        assert_eq!(1, get_read_count("postinst-systemd-restart"));
985                    }
986                    autoscript_fragments_to_check_for.insert("postinst.service");
987                } else if !options.no_start {
988                    assert_eq!(1, get_read_count("postinst-systemd-start"));
989                    autoscript_fragments_to_check_for.insert("postinst.service");
990                }
991                if options.restart_after_upgrade || options.no_stop_on_upgrade {
992                    assert_eq!(1, get_read_count("prerm-systemd-restart"));
993                    autoscript_fragments_to_check_for.insert("prerm.service");
994                } else if !options.no_start {
995                    assert_eq!(1, get_read_count("prerm-systemd"));
996                    autoscript_fragments_to_check_for.insert("prerm.service");
997                }
998                assert_eq!(1, get_read_count("postrm-systemd-reload-only"));
999                autoscript_fragments_to_check_for.insert("postrm.debhelper");
1000            },
1001            _ => unreachable!(),
1002        }
1003
1004        for autoscript in &autoscript_fragments_to_check_for {
1005            let key = format!("mypkg.{autoscript}");
1006            assert!(fragments.contains_key(&key), "{}", key);
1007        }
1008    }
1009}