Skip to main content

persistence/
path.rs

1//! A file's kind and account from its path on the host.
2
3use crate::Kind;
4
5/// Directories systemd reads units from, below the root.
6const UNIT_DIRECTORIES: [&[&str]; 9] = [
7    &["etc", "systemd", "system"],
8    &["etc", "systemd", "user"],
9    &["run", "systemd", "system"],
10    &["run", "systemd", "user"],
11    &["usr", "lib", "systemd", "system"],
12    &["usr", "lib", "systemd", "user"],
13    &["usr", "local", "lib", "systemd", "system"],
14    &["lib", "systemd", "system"],
15    &["lib", "systemd", "user"],
16];
17/// Directories systemd reads a user's units from, below their home.
18const HOME_UNIT_DIRECTORIES: [&[&str]; 2] = [
19    &[".config", "systemd", "user"],
20    &[".local", "share", "systemd", "user"],
21];
22const UNIT_TYPES: [&str; 4] = ["service", "timer", "path", "socket"];
23/// Directories of units another one pulls in (`multi-user.target.wants`).
24const DEPENDENCY_DIRECTORIES: [&str; 3] = [".wants", ".requires", ".upholds"];
25
26/// Start-up files in a home, and in `etc/skel` (copied to new homes).
27const HOME_START_UP_FILES: [&str; 10] = [
28    ".bashrc",
29    ".bash_profile",
30    ".bash_login",
31    ".bash_logout",
32    ".profile",
33    ".zshrc",
34    ".zshenv",
35    ".zprofile",
36    ".zlogin",
37    ".zlogout",
38];
39/// System-wide start-up files in `etc`.
40const ETC_START_UP_FILES: [&str; 7] = [
41    "profile",
42    "bash.bashrc",
43    "bashrc",
44    "zshrc",
45    "zshenv",
46    "zprofile",
47    "zlogin",
48];
49/// Directories in `var/spool/cron` that aren't a crontab.
50const CRON_SPOOL_DIRECTORIES: [&str; 5] = ["crontabs", "tabs", "atjobs", "atspool", "lastrun"];
51
52/// A file's kind from its path on the host: relative to the root
53/// (`etc/crontab`), absolute (`/etc/crontab`), or as a collection stores it
54/// (`[root]/etc/crontab`, `uac/[root]/etc/crontab`); `/` or `\` separated.
55/// `None` for files this crate doesn't read.
56#[must_use]
57pub fn detect(path: &str) -> Option<Kind> {
58    let parts = components(path);
59    let kind = match parts.as_slice() {
60        ["etc", "crontab"] | ["etc", "cron.d", _] => Kind::SystemCrontab,
61        ["var", "spool", "cron", "crontabs" | "tabs", _] | ["var", "cron" | "at", "tabs", _] => {
62            Kind::Crontab
63        }
64        ["var", "spool", "cron", name] if !CRON_SPOOL_DIRECTORIES.contains(name) => Kind::Crontab,
65        ["var", "spool", "cron", "atjobs", name]
66        | ["var", "spool", "at", name]
67        | ["var", "at", "jobs", name]
68            if *name != ".SEQ" =>
69        {
70            Kind::AtJob
71        }
72        ["etc", "init.d", _] | ["etc", "rc.d", "init.d", _] => Kind::InitScript,
73        ["etc", "pam.d", _] | ["etc", "pam.conf"] => Kind::Pam,
74        ["etc", "ssh", "sshd_config"] | ["etc", "ssh", "sshd_config.d", _] => Kind::SshdConfig,
75        ["etc" | "lib" | "run", "udev", "rules.d", name]
76        | ["usr", "lib", "udev", "rules.d", name]
77            if extension(name) == Some("rules") =>
78        {
79            Kind::Udev
80        }
81        ["etc", "xdg", "autostart", name] | [.., ".config", "autostart", name]
82            if extension(name) == Some("desktop") =>
83        {
84            Kind::XdgAutostart
85        }
86        ["etc", "modules"] => Kind::ModulesLoad,
87        ["etc" | "lib" | "run", "modules-load.d", name]
88        | ["usr", "lib", "modules-load.d", name]
89            if extension(name) == Some("conf") =>
90        {
91            Kind::ModulesLoad
92        }
93        ["etc" | "lib" | "run", "modprobe.d", name] | ["usr", "lib", "modprobe.d", name]
94            if extension(name) == Some("conf") =>
95        {
96            Kind::Modprobe
97        }
98        ["etc", "anacrontab"] => Kind::Anacrontab,
99        [.., ".ssh", "authorized_keys" | "authorized_keys2"] => Kind::AuthorizedKeys,
100        ["etc", "rc.local"] | ["etc", "rc.d", "rc.local"] | ["etc", "rc.local.d", "local.sh"] => {
101            Kind::RcLocal
102        }
103        ["etc", "ld.so.preload"] => Kind::LdSoPreload,
104        ["etc", "sudoers"]
105        | ["etc", "sudoers.d", _]
106        | ["usr", "local", "etc", "sudoers"]
107        | ["usr", "local", "etc", "sudoers.d", _] => Kind::Sudoers,
108        _ if is_unit(&parts) => Kind::SystemdUnit,
109        _ if is_start_up_file(&parts) => Kind::ShellInit,
110        _ => return None,
111    };
112    Some(kind)
113}
114
115/// The account whose home `path` is in: `home/<user>/…` (macOS's
116/// `Users/<user>/…`), or `root/…`.
117pub(crate) fn account(path: &str) -> Option<&str> {
118    match components(path).as_slice() {
119        ["home" | "Users", user, _, ..] => Some(user),
120        ["root", _, ..] => Some("root"),
121        _ => None,
122    }
123}
124
125/// The service a file in `etc/pam.d` configures: its name.
126pub(crate) fn pam_service(path: &str) -> Option<&str> {
127    match components(path).as_slice() {
128        ["etc", "pam.d", service] => Some(service),
129        _ => None,
130    }
131}
132
133/// The last component of `path`.
134pub(crate) fn file_name(path: &str) -> Option<&str> {
135    components(path).last().copied()
136}
137
138/// The path's components below the host's root.
139fn components(path: &str) -> Vec<&str> {
140    let below_root = path.rfind("[root]").map_or(path, |at| &path[at + 6..]);
141    below_root
142        .split(['/', '\\'])
143        .filter(|part| !part.is_empty() && *part != ".")
144        .collect()
145}
146
147/// A unit or drop-in in one of systemd's directories: `<dir>/x.service`,
148/// `<dir>/multi-user.target.wants/x.service`, `<dir>/x.service.d/y.conf`.
149fn is_unit(parts: &[&str]) -> bool {
150    let Some(within) = unit_directory_length(parts).map(|length| &parts[length..]) else {
151        return false;
152    };
153    match within {
154        [name] => is_unit_name(name),
155        [directory, name]
156            if DEPENDENCY_DIRECTORIES
157                .iter()
158                .any(|d| directory.ends_with(d)) =>
159        {
160            is_unit_name(name)
161        }
162        [directory, name] => directory
163            .strip_suffix(".d")
164            .is_some_and(|unit| is_unit_name(unit) && extension(name) == Some("conf")),
165        _ => false,
166    }
167}
168
169/// How many components name the unit directory `parts` starts with.
170fn unit_directory_length(parts: &[&str]) -> Option<usize> {
171    if let Some(directory) = UNIT_DIRECTORIES.iter().find(|d| parts.starts_with(d)) {
172        return Some(directory.len());
173    }
174    let home = home_length(parts)?;
175    HOME_UNIT_DIRECTORIES
176        .iter()
177        .find(|d| parts[home..].starts_with(d))
178        .map(|d| home + d.len())
179}
180
181fn is_unit_name(name: &str) -> bool {
182    extension(name)
183        .is_some_and(|suffix| name.len() > suffix.len() + 1 && UNIT_TYPES.contains(&suffix))
184}
185
186/// What follows the last `.`, as written: systemd, the shells' start-up
187/// scripts, udev, modprobe and desktop sessions match it case-sensitively.
188fn extension(name: &str) -> Option<&str> {
189    name.rsplit_once('.').map(|(_, extension)| extension)
190}
191
192/// How many components name the home `parts` starts with.
193fn home_length(parts: &[&str]) -> Option<usize> {
194    match parts {
195        ["home" | "Users", _, ..] | ["etc", "skel", ..] => Some(2),
196        ["root", ..] => Some(1),
197        _ => None,
198    }
199}
200
201fn is_start_up_file(parts: &[&str]) -> bool {
202    match parts {
203        ["etc", name] | ["etc", "zsh", name] => ETC_START_UP_FILES.contains(name),
204        ["etc", "profile.d", name] => extension(name) == Some("sh"),
205        _ => home_length(parts).is_some_and(|home| match &parts[home..] {
206            [name] => HOME_START_UP_FILES.contains(name),
207            _ => false,
208        }),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn kinds_from_paths() {
218        for (path, kind) in [
219            ("etc/crontab", Some(Kind::SystemCrontab)),
220            ("/etc/cron.d/e2scrub_all", Some(Kind::SystemCrontab)),
221            ("[root]/var/spool/cron/crontabs/root", Some(Kind::Crontab)),
222            ("var/spool/cron/alice", Some(Kind::Crontab)),
223            ("var/spool/cron/crontabs", None),
224            ("var/spool/cron/atjobs", None),
225            ("etc/anacrontab", Some(Kind::Anacrontab)),
226            ("etc/systemd/system/x.service", Some(Kind::SystemdUnit)),
227            (
228                "etc/systemd/system/multi-user.target.wants/x.service",
229                Some(Kind::SystemdUnit),
230            ),
231            (
232                "etc/systemd/system/ssh.service.d/override.conf",
233                Some(Kind::SystemdUnit),
234            ),
235            ("lib/systemd/system/cron.timer", Some(Kind::SystemdUnit)),
236            (
237                "home/alice/.config/systemd/user/agent.service",
238                Some(Kind::SystemdUnit),
239            ),
240            ("etc/systemd/system/default.target", None),
241            ("etc/systemd/system/.service", None),
242            ("etc/systemd/system.conf", None),
243            (
244                "home/alice/.ssh/authorized_keys",
245                Some(Kind::AuthorizedKeys),
246            ),
247            ("root/.ssh/authorized_keys2", Some(Kind::AuthorizedKeys)),
248            (
249                "var/lib/postgresql/.ssh/authorized_keys",
250                Some(Kind::AuthorizedKeys),
251            ),
252            ("home/alice/.ssh/known_hosts", None),
253            ("etc/rc.local", Some(Kind::RcLocal)),
254            ("etc/rc.d/rc.local", Some(Kind::RcLocal)),
255            ("etc/rc.local.d/local.sh", Some(Kind::RcLocal)),
256            ("etc/ld.so.preload", Some(Kind::LdSoPreload)),
257            ("etc/sudoers", Some(Kind::Sudoers)),
258            ("etc/sudoers.d/90-cloud-init-users", Some(Kind::Sudoers)),
259            ("usr/local/etc/sudoers", Some(Kind::Sudoers)),
260            ("etc/profile", Some(Kind::ShellInit)),
261            ("etc/profile.d/update.sh", Some(Kind::ShellInit)),
262            ("etc/profile.d/notes.txt", None),
263            ("etc/bash.bashrc", Some(Kind::ShellInit)),
264            ("etc/zsh/zshrc", Some(Kind::ShellInit)),
265            ("home/alice/.bashrc", Some(Kind::ShellInit)),
266            ("root/.profile", Some(Kind::ShellInit)),
267            ("etc/skel/.bashrc", Some(Kind::ShellInit)),
268            ("home/alice/docs/.bashrc", None),
269            ("C:\\case\\[root]\\etc\\crontab", Some(Kind::SystemCrontab)),
270            ("", None),
271            ("[root]", None),
272        ] {
273            assert_eq!(detect(path), kind, "{path}");
274        }
275    }
276
277    #[test]
278    fn accounts_from_paths() {
279        assert_eq!(account("home/alice/.ssh/authorized_keys"), Some("alice"));
280        assert_eq!(account("[root]/root/.bashrc"), Some("root"));
281        assert_eq!(account("Users/bob/.zshrc"), Some("bob"));
282        assert_eq!(account("etc/skel/.bashrc"), None);
283        assert_eq!(account("home/alice"), None);
284        assert_eq!(file_name("var/spool/cron/crontabs/carol"), Some("carol"));
285        assert_eq!(file_name("/"), None);
286    }
287}