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", "passwd"] => Kind::Passwd,
105        ["etc", "shadow"] => Kind::Shadow,
106        ["etc", "group"] => Kind::Group,
107        ["etc", "sudoers"]
108        | ["etc", "sudoers.d", _]
109        | ["usr", "local", "etc", "sudoers"]
110        | ["usr", "local", "etc", "sudoers.d", _] => Kind::Sudoers,
111        _ if is_unit(&parts) => Kind::SystemdUnit,
112        _ if is_start_up_file(&parts) => Kind::ShellInit,
113        _ => return None,
114    };
115    Some(kind)
116}
117
118/// The account whose home `path` is in: `home/<user>/…` (macOS's
119/// `Users/<user>/…`), or `root/…`.
120pub(crate) fn account(path: &str) -> Option<&str> {
121    match components(path).as_slice() {
122        ["home" | "Users", user, _, ..] => Some(user),
123        ["root", _, ..] => Some("root"),
124        _ => None,
125    }
126}
127
128/// The service a file in `etc/pam.d` configures: its name.
129pub(crate) fn pam_service(path: &str) -> Option<&str> {
130    match components(path).as_slice() {
131        ["etc", "pam.d", service] => Some(service),
132        _ => None,
133    }
134}
135
136/// The last component of `path`.
137pub(crate) fn file_name(path: &str) -> Option<&str> {
138    components(path).last().copied()
139}
140
141/// The path's components below the host's root.
142fn components(path: &str) -> Vec<&str> {
143    let below_root = path.rfind("[root]").map_or(path, |at| &path[at + 6..]);
144    below_root
145        .split(['/', '\\'])
146        .filter(|part| !part.is_empty() && *part != ".")
147        .collect()
148}
149
150/// A unit or drop-in in one of systemd's directories: `<dir>/x.service`,
151/// `<dir>/multi-user.target.wants/x.service`, `<dir>/x.service.d/y.conf`.
152fn is_unit(parts: &[&str]) -> bool {
153    let Some(within) = unit_directory_length(parts).map(|length| &parts[length..]) else {
154        return false;
155    };
156    match within {
157        [name] => is_unit_name(name),
158        [directory, name]
159            if DEPENDENCY_DIRECTORIES
160                .iter()
161                .any(|d| directory.ends_with(d)) =>
162        {
163            is_unit_name(name)
164        }
165        [directory, name] => directory
166            .strip_suffix(".d")
167            .is_some_and(|unit| is_unit_name(unit) && extension(name) == Some("conf")),
168        _ => false,
169    }
170}
171
172/// How many components name the unit directory `parts` starts with.
173fn unit_directory_length(parts: &[&str]) -> Option<usize> {
174    if let Some(directory) = UNIT_DIRECTORIES.iter().find(|d| parts.starts_with(d)) {
175        return Some(directory.len());
176    }
177    let home = home_length(parts)?;
178    HOME_UNIT_DIRECTORIES
179        .iter()
180        .find(|d| parts[home..].starts_with(d))
181        .map(|d| home + d.len())
182}
183
184fn is_unit_name(name: &str) -> bool {
185    extension(name)
186        .is_some_and(|suffix| name.len() > suffix.len() + 1 && UNIT_TYPES.contains(&suffix))
187}
188
189/// What follows the last `.`, as written: systemd, the shells' start-up
190/// scripts, udev, modprobe and desktop sessions match it case-sensitively.
191fn extension(name: &str) -> Option<&str> {
192    name.rsplit_once('.').map(|(_, extension)| extension)
193}
194
195/// How many components name the home `parts` starts with.
196fn home_length(parts: &[&str]) -> Option<usize> {
197    match parts {
198        ["home" | "Users", _, ..] | ["etc", "skel", ..] => Some(2),
199        ["root", ..] => Some(1),
200        _ => None,
201    }
202}
203
204fn is_start_up_file(parts: &[&str]) -> bool {
205    match parts {
206        ["etc", name] | ["etc", "zsh", name] => ETC_START_UP_FILES.contains(name),
207        ["etc", "profile.d", name] => extension(name) == Some("sh"),
208        _ => home_length(parts).is_some_and(|home| match &parts[home..] {
209            [name] => HOME_START_UP_FILES.contains(name),
210            _ => false,
211        }),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn kinds_from_paths() {
221        for (path, kind) in [
222            ("etc/crontab", Some(Kind::SystemCrontab)),
223            ("/etc/cron.d/e2scrub_all", Some(Kind::SystemCrontab)),
224            ("[root]/var/spool/cron/crontabs/root", Some(Kind::Crontab)),
225            ("var/spool/cron/alice", Some(Kind::Crontab)),
226            ("var/spool/cron/crontabs", None),
227            ("var/spool/cron/atjobs", None),
228            ("etc/anacrontab", Some(Kind::Anacrontab)),
229            ("etc/systemd/system/x.service", Some(Kind::SystemdUnit)),
230            (
231                "etc/systemd/system/multi-user.target.wants/x.service",
232                Some(Kind::SystemdUnit),
233            ),
234            (
235                "etc/systemd/system/ssh.service.d/override.conf",
236                Some(Kind::SystemdUnit),
237            ),
238            ("lib/systemd/system/cron.timer", Some(Kind::SystemdUnit)),
239            (
240                "home/alice/.config/systemd/user/agent.service",
241                Some(Kind::SystemdUnit),
242            ),
243            ("etc/systemd/system/default.target", None),
244            ("etc/systemd/system/.service", None),
245            ("etc/systemd/system.conf", None),
246            (
247                "home/alice/.ssh/authorized_keys",
248                Some(Kind::AuthorizedKeys),
249            ),
250            ("root/.ssh/authorized_keys2", Some(Kind::AuthorizedKeys)),
251            (
252                "var/lib/postgresql/.ssh/authorized_keys",
253                Some(Kind::AuthorizedKeys),
254            ),
255            ("home/alice/.ssh/known_hosts", None),
256            ("etc/rc.local", Some(Kind::RcLocal)),
257            ("etc/rc.d/rc.local", Some(Kind::RcLocal)),
258            ("etc/rc.local.d/local.sh", Some(Kind::RcLocal)),
259            ("etc/ld.so.preload", Some(Kind::LdSoPreload)),
260            ("etc/passwd", Some(Kind::Passwd)),
261            ("etc/shadow", Some(Kind::Shadow)),
262            ("etc/group", Some(Kind::Group)),
263            ("etc/passwd-", None),
264            ("etc/gshadow", None),
265            ("etc/sudoers", Some(Kind::Sudoers)),
266            ("etc/sudoers.d/90-cloud-init-users", Some(Kind::Sudoers)),
267            ("usr/local/etc/sudoers", Some(Kind::Sudoers)),
268            ("etc/profile", Some(Kind::ShellInit)),
269            ("etc/profile.d/update.sh", Some(Kind::ShellInit)),
270            ("etc/profile.d/notes.txt", None),
271            ("etc/bash.bashrc", Some(Kind::ShellInit)),
272            ("etc/zsh/zshrc", Some(Kind::ShellInit)),
273            ("home/alice/.bashrc", Some(Kind::ShellInit)),
274            ("root/.profile", Some(Kind::ShellInit)),
275            ("etc/skel/.bashrc", Some(Kind::ShellInit)),
276            ("home/alice/docs/.bashrc", None),
277            ("C:\\case\\[root]\\etc\\crontab", Some(Kind::SystemCrontab)),
278            ("", None),
279            ("[root]", None),
280        ] {
281            assert_eq!(detect(path), kind, "{path}");
282        }
283    }
284
285    #[test]
286    fn accounts_from_paths() {
287        assert_eq!(account("home/alice/.ssh/authorized_keys"), Some("alice"));
288        assert_eq!(account("[root]/root/.bashrc"), Some("root"));
289        assert_eq!(account("Users/bob/.zshrc"), Some("bob"));
290        assert_eq!(account("etc/skel/.bashrc"), None);
291        assert_eq!(account("home/alice"), None);
292        assert_eq!(file_name("var/spool/cron/crontabs/carol"), Some("carol"));
293        assert_eq!(file_name("/"), None);
294    }
295}