Skip to main content

pimalaya_cli/wizard/
caldav.rs

1//! Interactive CalDAV account setup wizard.
2
3use core::fmt;
4
5use secrecy::SecretString;
6
7use crate::prompt::{self, PromptResult};
8
9/// CalDAV account settings collected by the wizard.
10#[derive(Clone, Debug)]
11pub struct WizardCaldavConfig {
12    /// The CalDAV server hostname.
13    pub host: String,
14    /// The CalDAV server port.
15    pub port: u16,
16    /// The connection encryption scheme.
17    pub encryption: Encryption,
18    /// Optional path override on the discovered server (used when the
19    /// admin published the calendar home-set at a non-default URL).
20    pub home_url: Option<String>,
21    /// The authentication method and its secret.
22    pub auth: CaldavAuth,
23}
24
25/// Connection encryption scheme offered by the wizard.
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
27pub enum Encryption {
28    /// Implicit TLS negotiated on connection (the default).
29    #[default]
30    Tls,
31    /// No encryption (insecure).
32    None,
33}
34
35impl fmt::Display for Encryption {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Tls => f.write_str("Always (TLS)"),
39            Self::None => f.write_str("None (insecure)"),
40        }
41    }
42}
43
44/// CalDAV authentication method.
45#[derive(Clone, Debug)]
46pub enum CaldavAuth {
47    /// HTTP Basic authentication with a username and a password secret.
48    Basic {
49        /// The username sent during authentication.
50        username: String,
51        /// The password secret.
52        secret: CaldavSecret,
53    },
54    /// HTTP Bearer authentication with a token secret.
55    Bearer {
56        /// The token secret.
57        secret: CaldavSecret,
58    },
59}
60
61/// Source of a CalDAV secret (password or token).
62#[derive(Clone, Debug)]
63pub enum CaldavSecret {
64    /// The secret stored in plaintext in the configuration.
65    Raw(SecretString),
66    /// A shell command whose output is the secret.
67    Command(String),
68}
69
70const ENCRYPTIONS: [Encryption; 2] = [Encryption::Tls, Encryption::None];
71
72const CMD: &str = "Use a shell command to retrieve my secret (recommended)";
73const RAW: &str = "Save secret in the configuration file (plaintext, NOT recommended)";
74const SECRETS: [&str; 2] = [CMD, RAW];
75
76const BASIC: &str = "HTTP Basic (username + password)";
77const BEARER: &str = "HTTP Bearer (token)";
78const AUTHS: [&str; 2] = [BASIC, BEARER];
79
80/// Runs the interactive CalDAV account wizard, returning the collected
81/// settings.
82pub fn run(
83    account_name: impl AsRef<str>,
84    local_part: impl AsRef<str>,
85    domain: impl AsRef<str>,
86    defaults: Option<&WizardCaldavConfig>,
87) -> PromptResult<WizardCaldavConfig> {
88    let account_name = account_name.as_ref();
89    let local_part = local_part.as_ref();
90    let domain = domain.as_ref();
91
92    let default_host = defaults
93        .map(|c| c.host.clone())
94        .unwrap_or_else(|| format!("caldav.{domain}"));
95
96    let host = prompt::text("CalDAV hostname:", Some(&default_host))?;
97
98    let default_encryption = defaults.map(|c| c.encryption).unwrap_or_default();
99
100    let encryption = prompt::item("CalDAV encryption:", ENCRYPTIONS, Some(default_encryption))?;
101
102    let default_port = if encryption == default_encryption {
103        defaults
104            .map(|c| c.port)
105            .unwrap_or_else(|| default_port(encryption))
106    } else {
107        default_port(encryption)
108    };
109
110    let port = prompt::u16("CalDAV port:", Some(default_port))?;
111
112    let default_home_url = defaults
113        .and_then(|c| c.home_url.clone())
114        .unwrap_or_default();
115
116    let home_url = prompt::text(
117        "CalDAV home URL (leave blank to auto-discover):",
118        Some(&default_home_url),
119    )?;
120
121    let home_url = if home_url.trim().is_empty() {
122        None
123    } else {
124        Some(home_url)
125    };
126
127    let default_strategy = match defaults.map(|c| &c.auth) {
128        Some(CaldavAuth::Basic { .. }) => Some(BASIC),
129        Some(CaldavAuth::Bearer { .. }) => Some(BEARER),
130        None => None,
131    };
132
133    let strategy = prompt::item("CalDAV authentication strategy:", AUTHS, default_strategy)?;
134
135    let auth = match strategy {
136        BASIC => {
137            let default_username = defaults
138                .and_then(|c| match &c.auth {
139                    CaldavAuth::Basic { username, .. } if !username.is_empty() => {
140                        Some(username.clone())
141                    }
142                    _ => None,
143                })
144                .unwrap_or_else(|| format!("{local_part}@{domain}"));
145
146            let username = prompt::text("CalDAV username:", Some(&default_username))?;
147            let secret = prompt_secret(account_name, "password")?;
148
149            CaldavAuth::Basic { username, secret }
150        }
151        BEARER => {
152            let secret = prompt_secret(account_name, "token")?;
153            CaldavAuth::Bearer { secret }
154        }
155        _ => unreachable!(),
156    };
157
158    Ok(WizardCaldavConfig {
159        host,
160        port,
161        encryption,
162        home_url,
163        auth,
164    })
165}
166
167fn prompt_secret(account_name: &str, label: &str) -> PromptResult<CaldavSecret> {
168    let strategy = prompt::item("CalDAV secret strategy:", SECRETS, None)?;
169
170    match strategy {
171        CMD => {
172            let default_cmd = default_secret_cmd(account_name, "caldav");
173            let cmd = prompt::text("Shell command:", Some(&default_cmd))?;
174            Ok(CaldavSecret::Command(cmd))
175        }
176        RAW => {
177            let secret = prompt::password(
178                format!("CalDAV {label}:"),
179                format!("Confirm CalDAV {label}:"),
180            )?;
181            Ok(CaldavSecret::Raw(secret))
182        }
183        _ => unreachable!(),
184    }
185}
186
187fn default_secret_cmd(account_name: &str, protocol: &str) -> String {
188    if cfg!(target_os = "macos") {
189        format!(
190            "security find-generic-password \
191	     -a '{account_name}' \
192	     -s 'himalaya-{account_name}-{protocol}' \
193	     -w"
194        )
195    } else if cfg!(target_os = "linux") {
196        format!("secret-tool lookup account {account_name} service himalaya-{protocol}")
197    } else {
198        String::new()
199    }
200}
201
202fn default_port(encryption: Encryption) -> u16 {
203    match encryption {
204        Encryption::Tls => 443,
205        Encryption::None => 80,
206    }
207}