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", "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
115pub(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
125pub(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
133pub(crate) fn file_name(path: &str) -> Option<&str> {
135 components(path).last().copied()
136}
137
138fn 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
147fn 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
169fn 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
186fn extension(name: &str) -> Option<&str> {
189 name.rsplit_once('.').map(|(_, extension)| extension)
190}
191
192fn 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}