Skip to main content

persistence/
summary.rs

1//! One line saying what an entry does.
2
3use crate::{Account, AuthorizedKey, Detail, Entry, Password, SudoRule, UdevPair};
4
5impl Entry {
6    /// What the entry does, in a line: `@reboot as root: /tmp/.x/run`,
7    /// `ExecStart as svc: /usr/bin/agent`, `alice may run ALL as ALL:ALL
8    /// (NOPASSWD)`, `sshd: auth sufficient pam_permit.so`, ….
9    #[must_use]
10    pub fn summary(&self) -> String {
11        let command = self.command.as_deref().unwrap_or_default();
12        let schedule = self.schedule.as_deref().unwrap_or_default();
13        let as_user = self
14            .user
15            .as_deref()
16            .map(|user| format!(" as {user}"))
17            .unwrap_or_default();
18        match &self.detail {
19            Detail::Environment { name, value } => format!("{name}={value}"),
20            Detail::CronJob => format!("{schedule}{as_user}: {command}"),
21            Detail::AnacronJob { delay_minutes, id } => format!(
22                "{id} {} after {delay_minutes} min{as_user}: {command}",
23                anacron_period(schedule)
24            ),
25            Detail::UnitSetting {
26                section,
27                key,
28                value,
29                ..
30            } => match &self.command {
31                Some(command) => format!("{key}{as_user}: {command}"),
32                None => format!("[{section}] {key}={value}"),
33            },
34            Detail::AuthorizedKey(key) => {
35                key_summary(key, self.user.as_deref(), self.command.as_deref())
36            }
37            Detail::ShellCommand => format!("{command}{}", parenthesised(self.user.as_deref())),
38            Detail::PreloadLibrary => format!("preloaded into every program: {command}"),
39            Detail::SudoRule(rule) => rule_summary(rule),
40            Detail::SudoAlias {
41                alias_kind,
42                name,
43                members,
44            } => format!("{alias_kind} {name} = {}", members.join(", ")),
45            Detail::SudoDefaults { scope, settings } => {
46                format!(
47                    "Defaults{} {settings}",
48                    scope.as_deref().unwrap_or_default()
49                )
50            }
51            Detail::SudoInclude { path, directory } => {
52                let what = if *directory { "every file in " } else { "" };
53                format!("includes {what}{path}")
54            }
55            Detail::AtJob { .. } if schedule.is_empty() => format!("at job{as_user}: {command}"),
56            Detail::AtJob { .. } => format!("at {schedule}{as_user}: {command}"),
57            Detail::PamRule(rule) => {
58                let mut summary = format!(
59                    "{}: {} {} {}",
60                    rule.service, rule.rule_type, rule.control, rule.module
61                );
62                for argument in &rule.arguments {
63                    summary += " ";
64                    summary += argument;
65                }
66                summary
67            }
68            Detail::PamInclude(file) => format!("includes {file}"),
69            Detail::UdevRule(pairs) => udev_summary(pairs, self.command.as_deref()),
70            Detail::Autostart { name, disabled } => {
71                let name = name
72                    .as_deref()
73                    .map(|n| format!("{n}: "))
74                    .unwrap_or_default();
75                let off = if *disabled { " (disabled)" } else { "" };
76                format!("at login{as_user}: {name}{command}{off}")
77            }
78            Detail::KernelModule => format!("kernel module loaded at boot: {command}"),
79            Detail::Account(account) => account_summary(self.user.as_deref(), account),
80            Detail::Password(password) => password_summary(self.user.as_deref(), password),
81            Detail::Group { name, members, .. } => {
82                format!("group {name}: {}", members.join(", "))
83            }
84            Detail::ModprobeDirective {
85                directive,
86                module,
87                arguments,
88            } => format!("{directive} {module} {arguments}")
89                .trim_end()
90                .to_owned(),
91            Detail::SshdSetting {
92                key,
93                value,
94                condition,
95            } => match condition {
96                Some(condition) => format!("{key} {value} (Match {condition})"),
97                None => format!("{key} {value}"),
98            },
99        }
100    }
101}
102
103/// What a rule runs and when (its matches), or its pairs as written.
104fn udev_summary(pairs: &[UdevPair], command: Option<&str>) -> String {
105    let matches: Vec<String> = pairs
106        .iter()
107        .filter(|p| p.operator == "==" || p.operator == "!=")
108        .map(pair_text)
109        .collect();
110    match command {
111        Some(command) => format!("on {}: runs {command}", matches.join(", ")),
112        None => pairs.iter().map(pair_text).collect::<Vec<_>>().join(", "),
113    }
114}
115
116fn pair_text(pair: &UdevPair) -> String {
117    let attribute = pair
118        .attribute
119        .as_deref()
120        .map(|a| format!("{{{a}}}"))
121        .unwrap_or_default();
122    format!("{}{attribute}{}\"{}\"", pair.key, pair.operator, pair.value)
123}
124
125fn key_summary(key: &AuthorizedKey, user: Option<&str>, command: Option<&str>) -> String {
126    let fingerprint = key.fingerprint.as_deref().unwrap_or("(damaged key)");
127    let mut summary = format!("{} {fingerprint}", key.key_type);
128    if let Some(comment) = &key.comment {
129        summary += " ";
130        summary += comment;
131    }
132    if let Some(user) = user {
133        summary += " may log in as ";
134        summary += user;
135    }
136    if let Some(command) = command {
137        summary += ", forced command: ";
138        summary += command;
139    }
140    summary
141}
142
143fn rule_summary(rule: &SudoRule) -> String {
144    let run_as = rule.run_as.as_deref().unwrap_or("root");
145    let mut summary = format!(
146        "{} may run {} as {run_as}",
147        rule.users.join(", "),
148        rule.commands.join(", ")
149    );
150    if rule.hosts.iter().any(|host| host != "ALL") {
151        summary += " on ";
152        summary += &rule.hosts.join(", ");
153    }
154    if !rule.tags.is_empty() {
155        summary += " (";
156        summary += &rule.tags.join(", ");
157        summary += ")";
158    }
159    summary
160}
161
162/// `every 7 days`, `every day`, or the `@monthly` written.
163fn anacron_period(period: &str) -> String {
164    match period {
165        "1" => "every day".to_owned(),
166        _ if period.starts_with('@') => period.to_owned(),
167        _ => format!("every {period} days"),
168    }
169}
170
171fn parenthesised(user: Option<&str>) -> String {
172    user.map(|user| format!(" ({user})")).unwrap_or_default()
173}
174
175/// `toor (uid 0): /bin/bash, home /root`.
176fn account_summary(user: Option<&str>, account: &Account) -> String {
177    let uid = account
178        .uid
179        .map_or_else(|| "?".to_owned(), |uid| uid.to_string());
180    let shell = if account.shell.is_empty() {
181        "no shell"
182    } else {
183        &account.shell
184    };
185    format!(
186        "{} (uid {uid}): {shell}, home {}",
187        user.unwrap_or("?"),
188        account.home
189    )
190}
191
192/// `svc: password empty, changed 2026-10-04`.
193fn password_summary(user: Option<&str>, password: &Password) -> String {
194    let changed = password
195        .last_change
196        .as_deref()
197        .map(|day| format!(", changed {day}"))
198        .unwrap_or_default();
199    format!(
200        "{}: password {}{changed}",
201        user.unwrap_or("?"),
202        password.state.label()
203    )
204}
205
206#[cfg(test)]
207mod tests {
208    use crate::{parse, Kind};
209
210    fn summaries(kind: Kind, text: &str, path: &str) -> Vec<String> {
211        parse(kind, text.as_bytes(), path)
212            .entries
213            .iter()
214            .map(crate::Entry::summary)
215            .collect()
216    }
217
218    #[test]
219    fn one_line_each() {
220        assert_eq!(
221            summaries(
222                Kind::SystemCrontab,
223                "PATH=/bin\n@reboot root /tmp/x\n",
224                "etc/crontab"
225            ),
226            ["PATH=/bin", "@reboot as root: /tmp/x"]
227        );
228        assert_eq!(
229            summaries(
230                Kind::Anacrontab,
231                "7 10 cron.weekly run-parts /etc/cron.weekly",
232                "etc/anacrontab"
233            ),
234            ["cron.weekly every 7 days after 10 min as root: run-parts /etc/cron.weekly"]
235        );
236        assert_eq!(
237            summaries(
238                Kind::SystemdUnit,
239                "[Service]\nUser=svc\nExecStart=-/opt/a\n",
240                "etc/systemd/system/a.service"
241            ),
242            ["[Service] User=svc", "ExecStart as svc: /opt/a"]
243        );
244        assert_eq!(
245            summaries(Kind::ShellInit, "alias ls='ls -la'\n", "home/alice/.bashrc"),
246            ["alias ls='ls -la' (alice)"]
247        );
248        assert_eq!(
249            summaries(Kind::LdSoPreload, "/usr/lib/libx.so\n", "etc/ld.so.preload"),
250            ["preloaded into every program: /usr/lib/libx.so"]
251        );
252        assert_eq!(
253            summaries(
254                Kind::Sudoers,
255                "%sudo ALL=(ALL:ALL) ALL\nbob web1 = NOPASSWD: /usr/bin/systemctl\nDefaults:bob !lecture\n@includedir /etc/sudoers.d\n",
256                "etc/sudoers"
257            ),
258            [
259                "%sudo may run ALL as ALL:ALL",
260                "bob may run /usr/bin/systemctl as root on web1 (NOPASSWD)",
261                "Defaults:bob !lecture",
262                "includes every file in /etc/sudoers.d",
263            ]
264        );
265    }
266}