Skip to main content

dead_man_switch/
config.rs

1//! Configuration module for the Dead Man's Switch
2//! Contains functions and structs to handle the configuration.
3use crate::app;
4use crate::error::ConfigError;
5
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::fs::{self, File};
9use std::io::Write;
10use std::path::PathBuf;
11use uuid::Uuid;
12use zeroize::{Zeroize, ZeroizeOnDrop};
13
14/// Configuration struct used for the application
15///
16/// # Default
17///
18/// If the configuration file does not exist, it will be created with
19/// the default values.
20#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
21pub struct Config {
22    /// The username for the email account.
23    pub username: String,
24    /// The password for the email account.
25    pub password: String,
26    /// The SMTP server to use
27    pub smtp_server: String,
28    /// The port to use for the SMTP server.
29    pub smtp_port: u16,
30    /// The timeout to use for the SMTP server.
31    pub smtp_check_timeout: Option<u64>,
32    /// The message to send in the email if you fail to check in
33    /// after the `timer_warning` with the additional `timer_dead_man`
34    /// seconds have passed.
35    pub message: String,
36    /// The warning message if you fail to check in `timer_warning` seconds.
37    pub message_warning: String,
38    /// The subject of the email if you fail to check in
39    /// after the `timer_warning` with the additional `timer_dead_man`
40    /// seconds have passed.
41    pub subject: String,
42    /// The subject of the email if you fail to check in `timer_warning` seconds.
43    pub subject_warning: String,
44    /// The email address to send the email to.
45    pub to: String,
46    /// The email address to send the email from.
47    pub from: String,
48    /// Attachment to send with the email.
49    pub attachment: Option<String>,
50    /// Timer in seconds for the warning email.
51    pub timer_warning: u64,
52    /// Timer in seconds for the dead man's email.
53    pub timer_dead_man: u64,
54    /// Web interface password
55    pub web_password: String,
56    /// Cookie expiration to avoid need for login
57    pub cookie_exp_days: u64,
58    /// Log level for the web interface.
59    pub log_level: Option<String>,
60}
61
62impl Default for Config {
63    fn default() -> Self {
64        // Use the WEB_PASSWORD environment variable if set, otherwise generate
65        // a cryptographically secure random password. This prevents the security
66        // vulnerability of having a hardcoded default password that attackers
67        // could exploit.
68        let web_password = env::var("WEB_PASSWORD")
69            .ok()
70            .unwrap_or_else(|| Uuid::new_v4().to_string());
71        Self {
72            username: "me@example.com".to_string(),
73            password: "".to_string(),
74            smtp_server: "smtp.example.com".to_string(),
75            smtp_port: 587,
76            smtp_check_timeout: Some(5),
77            message: "I'm probably dead, go to Central Park NY under bench #137 you'll find an age-encrypted drive. Password is our favorite music in Pascal case.".to_string(),
78            message_warning: "Hey, you haven't checked in for a while. Are you okay?".to_string(),
79            subject: "[URGENT] Something Happened to Me!".to_string(),
80            subject_warning: "[URGENT] You need to check in!".to_string(),
81            to: "someone@example.com".to_string(),
82            from: "me@example.com".to_string(),
83            attachment: None,
84            timer_warning: 60 * 60 * 24 * 14, // 2 weeks
85            timer_dead_man: 60 * 60 * 24 * 7, // 1 week
86            web_password,
87            cookie_exp_days: 7,
88            log_level: None,
89        }
90    }
91}
92
93/// Enum to represent the type of email to send.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Email {
96    /// Send the warning email.
97    Warning,
98    /// Send the dead man's email.
99    DeadMan,
100}
101
102/// Returns the name of the config file
103fn file_name() -> &'static str {
104    "config.toml"
105}
106
107/// Get the configuration file path
108///
109/// # Errors
110///
111/// - Fails if the home directory cannot be found
112///
113pub fn file_path() -> Result<PathBuf, ConfigError> {
114    let path = app::file_path(file_name())?;
115    Ok(path)
116}
117
118/// Save the configuration file.
119///
120/// # Errors
121///
122/// - Fails if the home directory cannot be found
123/// - Fails if the config directory cannot be created
124///
125pub fn save(config: &Config) -> Result<(), ConfigError> {
126    let file_path = file_path()?;
127    let mut file = File::create(file_path)?;
128    let config = toml::to_string(config)?;
129
130    file.write_all(config.as_bytes())?;
131
132    Ok(())
133}
134
135/// Load or initialize the configuration file.
136///
137/// # Errors
138///
139/// - Fails if the home directory cannot be found
140/// - Fails if the config directory cannot be created
141///
142/// # Example
143///
144/// ```rust
145/// use dead_man_switch::config::load_or_initialize;
146/// let config = load_or_initialize().unwrap();
147/// ```
148///
149pub fn load_or_initialize() -> Result<Config, ConfigError> {
150    let file_path = file_path()?;
151    if !file_path.exists() {
152        let config = Config::default();
153        save(&config)?;
154
155        Ok(config)
156    } else {
157        let config_str = fs::read_to_string(&file_path)?;
158        let config: Config = toml::from_str(&config_str)?;
159
160        Ok(config)
161    }
162}
163
164/// Parses the attachment path from the [`Config`].
165///
166/// # Errors
167///
168/// - If the attachment path is not found.
169/// - If the attachment path is not a valid path.
170///
171pub fn attachment_path(config: &Config) -> Result<PathBuf, ConfigError> {
172    let attachment_path = config
173        .attachment
174        .as_ref()
175        .ok_or(ConfigError::AttachmentNotFound)?;
176    Ok(PathBuf::from(attachment_path))
177}
178
179#[cfg(test)]
180mod test {
181    use super::*;
182    use std::path::Path;
183
184    struct TestGuard;
185    impl TestGuard {
186        fn new(c: &Config) -> Self {
187            // setup before test
188
189            let file_path = file_path().expect("setup: failed file_path()");
190
191            // Ensure parent directory exists
192            if let Some(parent) = file_path.parent() {
193                fs::create_dir_all(parent).expect("setup: failed to create dir");
194            }
195            let mut file = File::create(file_path).expect("setup: failed to create file");
196            let c_str = toml::to_string(c).expect("setup: failed to convert data");
197            file.write_all(c_str.as_bytes())
198                .expect("setup: failed to write data");
199            file.sync_all()
200                .expect("setup: failed to ensure file written to disk");
201
202            TestGuard
203        }
204    }
205    impl Drop for TestGuard {
206        fn drop(&mut self) {
207            // clean-up after a test
208            let file_path = file_path().expect("teardown: failed file_path()");
209            cleanup_test_dir_parent(file_path.as_path());
210        }
211    }
212
213    // helper
214    fn cleanup_test_dir(dir: &Path) {
215        if let Some(parent) = dir.parent() {
216            let _ = fs::remove_dir_all(parent);
217        }
218    }
219
220    // helper
221    fn cleanup_test_dir_parent(dir: &Path) {
222        if let Some(parent) = dir.parent() {
223            cleanup_test_dir(parent)
224        }
225    }
226
227    // helper
228    fn load_config_from_path(path: &PathBuf) -> Config {
229        let config_str = fs::read_to_string(path).expect("helper: error reading config data");
230        let config: Config =
231            toml::from_str(&config_str).expect("helper: error parsing config data");
232        config
233    }
234
235    #[test]
236    fn file_path_in_test_mode() {
237        // This test verifies that file_path() uses temp directory in test mode
238        let result = file_path();
239        assert!(result.is_ok());
240
241        let result = result.unwrap();
242        let expected = format!("{}_test", app::name());
243        assert!(result.to_string_lossy().contains(expected.as_str()));
244
245        // It should also of course contain the actual file name
246        let expected = Path::new(app::name()).join(file_name());
247        assert!(result
248            .to_string_lossy()
249            .contains(expected.to_string_lossy().as_ref()));
250
251        // Cleanup any created directories
252        cleanup_test_dir_parent(&result);
253    }
254
255    #[test]
256    fn save_config() {
257        // Set state for this test
258        let mut config = Config::default();
259        config.message = "test save".to_string();
260
261        let result = save(&config);
262        assert!(result.is_ok());
263
264        let test_path = file_path().unwrap();
265        // Compare against the same config instance that was saved,
266        // not a new default (which would have a different random password)
267        let loaded_config = load_config_from_path(&test_path);
268        // Compare against the original config instance
269        assert_eq!(loaded_config, config);
270
271        // Cleanup any created directories
272        cleanup_test_dir_parent(&test_path);
273    }
274
275    #[test]
276    fn timer_guard_ok() {
277        // This test verifies that the guard is working as expected
278        // by saving a timer and reading it back
279
280        // Set state for this test
281        let mut config = Config::default();
282        config.message = "test guard".to_string();
283        let _guard = TestGuard::new(&config);
284
285        // Compare against the same config instance saved by guard
286        let test_path = file_path().unwrap();
287        let loaded_config = load_config_from_path(&test_path);
288        assert_eq!(loaded_config, config);
289    }
290
291    #[test]
292    fn load_or_initialize_with_existing_file() {
293        // Set state for this test
294        let mut existing_config = Config::default();
295        existing_config.message = "test load".to_string();
296        let _guard = TestGuard::new(&existing_config);
297
298        // With config data persisted, we should see a config with those values
299        let config = load_or_initialize().unwrap();
300
301        // Compare against the same config instance that was saved,
302        // not a new default (which would have a different random password)
303        assert_eq!(config, existing_config);
304    }
305
306    #[test]
307    fn load_or_initialize_with_no_existing_file() {
308        let mut config_default = Config::default();
309
310        // With no previous data persisted, we should see a config with defaults
311        let mut config = load_or_initialize().unwrap();
312
313        // deal with the the random web_password generation
314        config_default.web_password = "".to_string();
315        config.web_password = "".to_string();
316
317        assert_eq!(config, config_default);
318
319        // Cleanup any created directories
320        let test_path = file_path().unwrap();
321        cleanup_test_dir_parent(&test_path);
322    }
323
324    #[test]
325    fn example_config_is_valid() {
326        let example_config = fs::read_to_string("../../config.example.toml").unwrap();
327        let config: Result<Config, toml::de::Error> = toml::from_str(&example_config);
328        assert!(config.is_ok());
329    }
330}