Skip to main content

pacsea/logic/
doas_persist_setup.rs

1//! Helpers for the optional `doas` persist setup wizard.
2
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5
6use crate::state::AppState;
7use crate::state::modal::DoasPersistChoice;
8use crate::theme::theme;
9
10/// Visible instruction lines in the doas persist wizard (scroll viewport height).
11pub const DOAS_PERSIST_INSTRUCTION_VIEWPORT_LINES: usize = 10;
12
13/// What: Best-effort current username resolution for display-only snippet guidance.
14///
15/// Inputs: None.
16///
17/// Output:
18/// - Username string to embed in suggested user-scoped `doas.conf` snippets.
19///
20/// Details:
21/// - Prefers `$USER`, then `$LOGNAME`.
22/// - Falls back to `id -un` when env vars are unavailable.
23/// - Final fallback is `"youruser"` to keep guidance deterministic.
24fn current_username_for_snippet() -> String {
25    let from_env = std::env::var("USER")
26        .or_else(|_| std::env::var("LOGNAME"))
27        .ok()
28        .map(|s| s.trim().to_string())
29        .filter(|s| !s.is_empty());
30    if let Some(name) = from_env {
31        return name;
32    }
33    let from_id = std::process::Command::new("id")
34        .args(["-un"])
35        .output()
36        .ok()
37        .filter(|out| out.status.success())
38        .and_then(|out| String::from_utf8(out.stdout).ok())
39        .map(|s| s.trim().to_string())
40        .filter(|s| !s.is_empty());
41    from_id.unwrap_or_else(|| "youruser".to_string())
42}
43
44/// What: Check whether a parsed `doas.conf` body has at least one active `permit persist` rule.
45///
46/// Inputs:
47/// - `contents`: Raw text contents from `doas.conf`.
48///
49/// Output:
50/// - `true` if a non-comment rule line includes both `permit` and `persist`.
51///
52/// Details:
53/// - Ignores blank lines and comments (`# ...`).
54/// - Uses token matching to avoid false positives from unrelated substrings.
55#[must_use]
56pub fn doas_conf_has_persist_rule(contents: &str) -> bool {
57    contents.lines().any(|line| {
58        let trimmed = line.trim();
59        if trimmed.is_empty() || trimmed.starts_with('#') {
60            return false;
61        }
62        let without_inline_comment = trimmed
63            .split_once('#')
64            .map_or(trimmed, |(rule, _)| rule.trim());
65        let tokens: Vec<&str> = without_inline_comment.split_whitespace().collect();
66        tokens.contains(&"permit") && tokens.contains(&"persist")
67    })
68}
69
70/// What: Detect whether the system `doas.conf` appears configured with a persist rule.
71///
72/// Inputs: None.
73///
74/// Output:
75/// - `true` when `/etc/doas.conf` exists and contains an active `permit persist` rule.
76///
77/// Details:
78/// - Read-only check; never mutates system files.
79/// - Returns `false` on missing/unreadable config or parse mismatch.
80#[must_use]
81pub fn pacsea_doas_persist_configured() -> bool {
82    std::fs::read_to_string("/etc/doas.conf")
83        .ok()
84        .is_some_and(|contents| doas_conf_has_persist_rule(&contents))
85}
86
87/// What: Build a recommended `doas.conf` persist snippet for the selected profile.
88///
89/// Inputs:
90/// - `choice`: Selected snippet profile.
91///
92/// Output:
93/// - A single-line `permit persist` recommendation.
94///
95/// Details:
96/// - Generated text is guidance only. The app never writes `/etc/doas.conf` automatically.
97#[must_use]
98pub fn doas_persist_snippet(choice: DoasPersistChoice, username: &str) -> String {
99    match choice {
100        DoasPersistChoice::WheelScoped => "permit persist :wheel as root".to_string(),
101        DoasPersistChoice::UserScoped => format!("permit persist {username} as root"),
102        DoasPersistChoice::Skip => "# Skip setup".to_string(),
103    }
104}
105
106/// What: Build shell commands for validating doas policy configuration.
107///
108/// Inputs: None.
109///
110/// Output:
111/// - Validation command list (`doas -C`, optional non-interactive probe).
112///
113/// Details:
114/// - `doas -C /etc/doas.conf` validates syntax and rule matching.
115/// - `doas -n true` is a non-interactive capability probe and may fail when `nopass` is not configured.
116#[must_use]
117pub fn validation_commands() -> Vec<&'static str> {
118    vec!["doas -C /etc/doas.conf", "doas -n true"]
119}
120
121/// What: Build scrollable instruction lines for the doas persist setup wizard.
122///
123/// Inputs:
124/// - `app`: Application state for localized strings.
125/// - `choice`: Selected persist setup profile.
126///
127/// Output:
128/// - Owned lines for the instructions pane.
129///
130/// Details:
131/// - Used by the renderer and key handler for scroll clamping.
132#[must_use]
133#[allow(clippy::vec_init_then_push)]
134pub fn doas_persist_instruction_lines(
135    app: &AppState,
136    choice: DoasPersistChoice,
137) -> Vec<Line<'static>> {
138    let th = theme();
139    let user = current_username_for_snippet();
140    let snippet = doas_persist_snippet(choice, &user);
141    let mut lines: Vec<Line<'static>> = Vec::new();
142    lines.push(Line::from(Span::styled(
143        crate::i18n::t(app, "app.modals.doas_persist_setup.instructions_heading"),
144        Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
145    )));
146    lines.push(Line::from(""));
147    lines.push(Line::from(Span::styled(
148        crate::i18n::t(app, "app.modals.doas_persist_setup.instructions_note"),
149        Style::default().fg(th.text),
150    )));
151    lines.push(Line::from(""));
152    lines.push(Line::from(Span::styled(
153        crate::i18n::t(app, "app.modals.doas_persist_setup.label_snippet"),
154        Style::default()
155            .fg(th.overlay1)
156            .add_modifier(Modifier::BOLD),
157    )));
158    lines.push(Line::from(Span::styled(
159        snippet,
160        Style::default().fg(th.lavender),
161    )));
162    lines.push(Line::from(""));
163    lines.push(Line::from(Span::styled(
164        crate::i18n::t(app, "app.modals.doas_persist_setup.label_checks"),
165        Style::default()
166            .fg(th.overlay1)
167            .add_modifier(Modifier::BOLD),
168    )));
169    for cmd in validation_commands() {
170        lines.push(Line::from(Span::styled(
171            format!("  {cmd}"),
172            Style::default().fg(th.subtext1),
173        )));
174    }
175    lines.push(Line::from(""));
176    lines.push(Line::from(Span::styled(
177        crate::i18n::t(app, "app.modals.doas_persist_setup.instructions_footer"),
178        Style::default().fg(th.overlay1),
179    )));
180    lines
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn snippet_generation_uses_expected_forms() {
189        assert_eq!(
190            doas_persist_snippet(DoasPersistChoice::WheelScoped, "alice"),
191            "permit persist :wheel as root"
192        );
193        assert_eq!(
194            doas_persist_snippet(DoasPersistChoice::UserScoped, "alice"),
195            "permit persist alice as root"
196        );
197    }
198
199    #[test]
200    fn validation_commands_include_required_checks() {
201        let cmds = validation_commands();
202        assert!(cmds.iter().any(|c| c.contains("doas -C /etc/doas.conf")));
203        assert!(cmds.iter().any(|c| c.contains("doas -n true")));
204    }
205
206    #[test]
207    fn doas_conf_parser_detects_active_persist_rule() {
208        let conf = r"
209            # comment
210            permit persist :wheel as root
211        ";
212        assert!(doas_conf_has_persist_rule(conf));
213    }
214
215    #[test]
216    fn doas_conf_parser_ignores_commented_persist_rule() {
217        let conf = r"
218            # permit persist :wheel as root
219            permit :wheel as root
220        ";
221        assert!(!doas_conf_has_persist_rule(conf));
222    }
223}