1use crate::Kind;
4
5const 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];
17const HOME_UNIT_DIRECTORIES: [&[&str]; 2] = [
19 &[".config", "systemd", "user"],
20 &[".local", "share", "systemd", "user"],
21];
22const UNIT_TYPES: [&str; 4] = ["service", "timer", "path", "socket"];
23const DEPENDENCY_DIRECTORIES: [&str; 3] = [".wants", ".requires", ".upholds"];
25
26const 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];
39const ETC_START_UP_FILES: [&str; 7] = [
41 "profile",
42 "bash.bashrc",
43 "bashrc",
44 "zshrc",
45 "zshenv",
46 "zprofile",
47 "zlogin",
48];
49const CRON_SPOOL_DIRECTORIES: [&str; 5] = ["crontabs", "tabs", "atjobs", "atspool", "lastrun"];
51
52#[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
118pub(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
128pub(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
136pub(crate) fn file_name(path: &str) -> Option<&str> {
138 components(path).last().copied()
139}
140
141fn 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
150fn 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
172fn 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
189fn extension(name: &str) -> Option<&str> {
192 name.rsplit_once('.').map(|(_, extension)| extension)
193}
194
195fn 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}