Skip to main content

pacsea/logic/
sudo_timestamp_setup.rs

1//! Helpers for the optional `sudo` credential cache (`timestamp_timeout`) setup wizard.
2
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5
6use crate::state::AppState;
7use crate::state::modal::SudoTimestampChoice;
8use crate::theme::theme;
9
10/// Visible instruction lines in the sudo timestamp wizard (scroll viewport height).
11pub const SUDO_TIMESTAMP_INSTRUCTION_VIEWPORT_LINES: usize = 9;
12
13/// What: Build scrollable instruction lines for the sudo timestamp setup wizard.
14///
15/// Inputs:
16/// - `app`: Application state for localized strings.
17/// - `choice`: Selected `timestamp_timeout` mapping.
18///
19/// Output:
20/// - Owned lines for the instructions pane (excluding the modal title block).
21///
22/// Details:
23/// - Used by the renderer and by the key handler for scroll clamping.
24#[must_use]
25#[allow(clippy::vec_init_then_push)] // Imperative layout mirrors the modal text structure.
26pub fn sudo_timestamp_instruction_lines(
27    app: &AppState,
28    choice: SudoTimestampChoice,
29) -> Vec<Line<'static>> {
30    let th = theme();
31    let user = std::env::var("USER").unwrap_or_else(|_| "youruser".to_string());
32    let heading_key = match choice {
33        SudoTimestampChoice::TenMinutes => {
34            "app.modals.sudo_timestamp_setup.instructions_heading_ten"
35        }
36        SudoTimestampChoice::ThirtyMinutes => {
37            "app.modals.sudo_timestamp_setup.instructions_heading_thirty"
38        }
39        SudoTimestampChoice::Infinity => {
40            "app.modals.sudo_timestamp_setup.instructions_heading_infinity"
41        }
42    };
43    let mut lines: Vec<Line<'static>> = Vec::new();
44    lines.push(Line::from(Span::styled(
45        crate::i18n::t(app, heading_key),
46        Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
47    )));
48    lines.push(Line::from(""));
49    lines.push(Line::from(Span::styled(
50        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.instructions_note_tui"),
51        Style::default().fg(th.text),
52    )));
53    lines.push(Line::from(""));
54    lines.push(Line::from(Span::styled(
55        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.label_dropin_contents"),
56        Style::default()
57            .fg(th.overlay1)
58            .add_modifier(Modifier::BOLD),
59    )));
60    for l in sudoers_defaults_line(choice).lines() {
61        lines.push(Line::from(Span::styled(
62            l.to_string(),
63            Style::default().fg(th.lavender),
64        )));
65    }
66    lines.push(Line::from(""));
67    lines.push(Line::from(Span::styled(
68        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.label_user_scoped"),
69        Style::default()
70            .fg(th.overlay1)
71            .add_modifier(Modifier::BOLD),
72    )));
73    for l in sudoers_user_scoped_line(&user, choice).lines() {
74        lines.push(Line::from(Span::styled(
75            l.to_string(),
76            Style::default().fg(th.lavender),
77        )));
78    }
79    lines.push(Line::from(""));
80    lines.push(Line::from(Span::styled(
81        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.label_manual"),
82        Style::default()
83            .fg(th.overlay1)
84            .add_modifier(Modifier::BOLD),
85    )));
86    lines.push(Line::from(Span::styled(
87        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.manual_step_1"),
88        Style::default().fg(th.subtext1),
89    )));
90    lines.push(Line::from(Span::styled(
91        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.manual_step_2"),
92        Style::default().fg(th.subtext1),
93    )));
94    lines.push(Line::from(Span::styled(
95        crate::i18n::t(app, "app.modals.sudo_timestamp_setup.manual_step_3"),
96        Style::default().fg(th.subtext1),
97    )));
98    lines
99}
100
101/// Drop-in file path suggested by the wizard (stable name for detection).
102pub const SUDOERS_DROP_IN_PATH: &str = "/etc/sudoers.d/99-pacsea-timestamp";
103
104/// Marker comment inside the drop-in so we can detect Pacsea-created files when readable.
105const DROP_IN_MARKER: &str = "Pacsea optional:";
106
107/// What: Map a wizard choice to the `sudoers` `timestamp_timeout` value.
108///
109/// Inputs:
110/// - `choice`: User-selected duration.
111///
112/// Output:
113/// - Minutes as a positive integer, or `-1` for no expiry in the session (per `sudoers(5)`).
114///
115/// Details:
116/// - `-1` behavior depends on sudo version/policy; document in UI copy.
117#[must_use]
118pub const fn timestamp_timeout_value(choice: SudoTimestampChoice) -> i32 {
119    match choice {
120        SudoTimestampChoice::TenMinutes => 10,
121        SudoTimestampChoice::ThirtyMinutes => 30,
122        SudoTimestampChoice::Infinity => -1,
123    }
124}
125
126/// What: Build a single-line `Defaults` entry for a global `timestamp_timeout`.
127///
128/// Inputs:
129/// - `choice`: Selected cache duration.
130///
131/// Output:
132/// - One or two lines: marker comment plus `Defaults timestamp_timeout=…`.
133///
134/// Details:
135/// - Intended for `/etc/sudoers.d/*`; validate with `visudo -cf` before installing.
136#[must_use]
137pub fn sudoers_defaults_line(choice: SudoTimestampChoice) -> String {
138    let v = timestamp_timeout_value(choice);
139    format!(
140        "# {DROP_IN_MARKER} extend sudo credential cache for long installs/updates\nDefaults timestamp_timeout={v}"
141    )
142}
143
144/// What: Build a user-scoped `Defaults` line alternative for the instructions pane.
145///
146/// Inputs:
147/// - `username`: Target login name (typically `$USER`).
148/// - `choice`: Selected cache duration.
149///
150/// Output:
151/// - Two lines: marker comment plus `Defaults:username timestamp_timeout=…`.
152///
153/// Details:
154/// - Safer than a global `Defaults` on shared systems; requires correct username spelling.
155#[must_use]
156pub fn sudoers_user_scoped_line(username: &str, choice: SudoTimestampChoice) -> String {
157    let v = timestamp_timeout_value(choice);
158    format!(
159        "# {DROP_IN_MARKER} extend sudo credential cache for this user only\nDefaults:{username} timestamp_timeout={v}"
160    )
161}
162
163/// What: Detect whether the suggested Pacsea drop-in is already present and readable.
164///
165/// Inputs:
166/// - None; reads [`SUDOERS_DROP_IN_PATH`] from disk.
167///
168/// Output:
169/// - `true` when the file is readable and mentions our marker and `timestamp_timeout`.
170///
171/// Details:
172/// - Best-effort only: unreadable paths (permission denied) yield `false`.
173#[must_use]
174pub fn pacsea_sudo_timestamp_drop_in_present() -> bool {
175    std::fs::read_to_string(SUDOERS_DROP_IN_PATH)
176        .ok()
177        .is_some_and(|s| s.contains(DROP_IN_MARKER) && s.contains("timestamp_timeout"))
178}
179
180/// What: Build a POSIX shell script that validates and installs the drop-in with `sudo`.
181///
182/// Inputs:
183/// - `choice`: Selected cache duration.
184///
185/// Output:
186/// - A script suitable for `spawn_shell_commands_in_terminal`: writes temp file, `visudo -cf`, `install -m 0440`.
187///
188/// Details:
189/// - Ends with `read` so the user can see success/failure in an external terminal.
190/// - Uses [`SUDOERS_DROP_IN_PATH`] as the destination path.
191#[must_use]
192pub fn apply_drop_in_shell_script(choice: SudoTimestampChoice) -> String {
193    let block = sudoers_defaults_line(choice);
194    let path = SUDOERS_DROP_IN_PATH;
195    format!(
196        r#"set -euo pipefail
197DROP_IN="{path}"
198TMP="$(mktemp)"
199trap 'rm -f "$TMP"' EXIT
200cat <<'EOF' >"$TMP"
201{block}
202EOF
203sudo visudo -cf "$TMP"
204sudo install -m 0440 "$TMP" "$DROP_IN"
205echo "Installed $DROP_IN"
206read -r -p "Press Enter to close... " _
207"#
208    )
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn timestamp_timeout_value_maps_choices() {
217        assert_eq!(timestamp_timeout_value(SudoTimestampChoice::TenMinutes), 10);
218        assert_eq!(
219            timestamp_timeout_value(SudoTimestampChoice::ThirtyMinutes),
220            30
221        );
222        assert_eq!(timestamp_timeout_value(SudoTimestampChoice::Infinity), -1);
223    }
224
225    #[test]
226    fn sudoers_defaults_line_contains_marker_and_defaults() {
227        let s = sudoers_defaults_line(SudoTimestampChoice::ThirtyMinutes);
228        assert!(s.contains("Defaults timestamp_timeout=30"));
229        assert!(s.contains(DROP_IN_MARKER));
230    }
231
232    #[test]
233    fn sudoers_user_scoped_line_contains_username() {
234        let s = sudoers_user_scoped_line("alice", SudoTimestampChoice::TenMinutes);
235        assert!(s.contains("Defaults:alice timestamp_timeout=10"));
236    }
237
238    #[test]
239    fn apply_script_runs_visudo_and_install() {
240        let script = apply_drop_in_shell_script(SudoTimestampChoice::Infinity);
241        assert!(script.contains("visudo -cf"));
242        assert!(script.contains("install -m 0440"));
243        assert!(script.contains(SUDOERS_DROP_IN_PATH));
244    }
245}