1use crate::{Account, AuthorizedKey, Detail, Entry, Password, SudoRule, UdevPair};
4
5impl Entry {
6 #[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
103fn 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
162fn 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
175fn 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
192fn 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}