Skip to main content

pimalaya_cli/wizard/
carddav.rs

1//! Interactive CardDAV account setup wizard.
2
3use secrecy::SecretString;
4
5use crate::prompt::{self, PromptResult};
6
7/// Context and prompt defaults for the CardDAV wizard.
8///
9/// Doubles as the wizard's return value: `account_name` / `project_name`
10/// seed the default secret command and are carried back untouched, and
11/// `email` (when set) seeds the username default. The wizard collects
12/// authentication only; the server is resolved by discovery, not
13/// prompted.
14#[derive(Clone, Debug, Default)]
15pub struct WizardCarddavConfig {
16    /// Account name, used to seed the default secret command.
17    pub account_name: String,
18    /// Project (binary) name, used to seed the default secret command.
19    pub project_name: String,
20    /// Account email, used as the username default when set.
21    pub email: Option<String>,
22    /// The authentication method and its secret.
23    pub auth: CarddavAuth,
24    /// When set, drop the Basic option from the authentication-strategy
25    /// prompt (e.g. Google, which only accepts OAuth 2.0 tokens).
26    pub bearer_only: bool,
27}
28
29/// CardDAV authentication mechanism collected by the wizard.
30#[derive(Clone, Debug)]
31pub enum CarddavAuth {
32    /// HTTP Basic authentication with a username and a password secret.
33    Basic {
34        /// The username sent during authentication.
35        username: String,
36        /// The password secret.
37        secret: CarddavSecret,
38    },
39    /// HTTP Bearer authentication with a token secret.
40    Bearer {
41        /// The token secret.
42        secret: CarddavSecret,
43    },
44}
45
46impl Default for CarddavAuth {
47    fn default() -> Self {
48        Self::Basic {
49            username: String::new(),
50            secret: CarddavSecret::default(),
51        }
52    }
53}
54
55/// Secret source collected by the wizard: an inline value or a shell
56/// command line.
57#[derive(Clone, Debug)]
58pub enum CarddavSecret {
59    /// The secret stored in plaintext in the configuration.
60    Raw(SecretString),
61    /// A shell command whose output is the secret.
62    Command(String),
63}
64
65impl Default for CarddavSecret {
66    fn default() -> Self {
67        Self::Command(String::new())
68    }
69}
70
71const CMD: &str = "Use a shell command to retrieve my secret (recommended)";
72const RAW: &str = "Save secret in the configuration file (plaintext, NOT recommended)";
73const SECRETS: [&str; 2] = [CMD, RAW];
74
75const BASIC: &str = "Basic (username + password)";
76const BEARER: &str = "Bearer (token)";
77const AUTHS: [&str; 2] = [BASIC, BEARER];
78
79/// Runs the interactive CardDAV account wizard, returning the collected
80/// settings.
81pub fn run(defaults: &WizardCarddavConfig) -> PromptResult<WizardCarddavConfig> {
82    // NOTE: Bearer-only providers (e.g. Google) still get the strategy prompt,
83    // just without the Basic option.
84    let strategies: &[&str] = if defaults.bearer_only {
85        &[BEARER]
86    } else {
87        &AUTHS
88    };
89
90    let default_strategy = match &defaults.auth {
91        CarddavAuth::Basic { .. } => BASIC,
92        CarddavAuth::Bearer { .. } => BEARER,
93    };
94
95    let strategy = prompt::item(
96        "CardDAV authentication strategy:",
97        strategies.iter().copied(),
98        Some(default_strategy),
99    )?;
100
101    let auth = match strategy {
102        BASIC => {
103            let default_username = match &defaults.auth {
104                CarddavAuth::Basic { username, .. } if !username.is_empty() => {
105                    Some(username.clone())
106                }
107                _ => defaults.email.clone(),
108            };
109
110            let username = prompt::text("CardDAV username:", default_username.as_deref())?;
111            let secret = prompt_secret(
112                &defaults.account_name,
113                &defaults.project_name,
114                "password",
115                auth_secret(&defaults.auth),
116            )?;
117
118            CarddavAuth::Basic { username, secret }
119        }
120        BEARER => {
121            let secret = prompt_secret(
122                &defaults.account_name,
123                &defaults.project_name,
124                "token",
125                auth_secret(&defaults.auth),
126            )?;
127            CarddavAuth::Bearer { secret }
128        }
129        _ => unreachable!(),
130    };
131
132    Ok(WizardCarddavConfig {
133        account_name: defaults.account_name.clone(),
134        project_name: defaults.project_name.clone(),
135        email: defaults.email.clone(),
136        auth,
137        bearer_only: defaults.bearer_only,
138    })
139}
140
141/// The secret carried by either auth variant, used to seed the secret
142/// strategy and command defaults when editing.
143fn auth_secret(auth: &CarddavAuth) -> &CarddavSecret {
144    match auth {
145        CarddavAuth::Basic { secret, .. } => secret,
146        CarddavAuth::Bearer { secret } => secret,
147    }
148}
149
150fn prompt_secret(
151    account_name: &str,
152    project_name: &str,
153    label: &str,
154    default: &CarddavSecret,
155) -> PromptResult<CarddavSecret> {
156    let default_strategy = match default {
157        CarddavSecret::Command(_) => CMD,
158        CarddavSecret::Raw(_) => RAW,
159    };
160
161    let strategy = prompt::item("CardDAV secret strategy:", SECRETS, Some(default_strategy))?;
162
163    match strategy {
164        CMD => {
165            let default_cmd = match default {
166                CarddavSecret::Command(cmd) if !cmd.is_empty() => cmd.clone(),
167                _ => default_secret_cmd(account_name, project_name, "carddav"),
168            };
169            let cmd = prompt::text("Shell command:", Some(&default_cmd))?;
170            Ok(CarddavSecret::Command(cmd))
171        }
172        RAW => {
173            let secret = prompt::password(
174                format!("CardDAV {label}:"),
175                format!("Confirm CardDAV {label}:"),
176            )?;
177            Ok(CarddavSecret::Raw(secret))
178        }
179        _ => unreachable!(),
180    }
181}
182
183fn default_secret_cmd(account_name: &str, project_name: &str, protocol: &str) -> String {
184    if cfg!(target_os = "macos") {
185        format!(
186            "security find-generic-password \
187             -a '{account_name}' \
188             -s '{project_name}-{account_name}-{protocol}' \
189             -w"
190        )
191    } else if cfg!(target_os = "linux") {
192        format!("secret-tool lookup account {account_name} service {project_name}-{protocol}")
193    } else {
194        String::new()
195    }
196}