Skip to main content

volga_dev_cert/
lib.rs

1//! A Rust library for generating self-signed TLS certificates for local development.
2
3use rcgen::{CertifiedKey, generate_simple_self_signed};
4use std::{
5    fs,
6    io::{Error, Result, Write},
7    path::PathBuf,
8};
9
10/// Default name of a folder with TLS certificates
11pub const DEFAULT_CERT_FOLDER: &str = "cert";
12
13/// Default name of development certificate file
14pub const DEFAULT_CERT_FILE_NAME: &str = "dev-cert.pem";
15
16/// Default name of signing key file
17pub const DEFAULT_KEY_FILE_NAME: &str = "dev-key.pem";
18
19/// Default certificate names
20#[cfg(target_os = "windows")]
21pub const DEV_CERT_NAMES: &[&str] = &["localhost"];
22/// Default certificate names
23#[cfg(not(target_os = "windows"))]
24pub const DEV_CERT_NAMES: &[&str] = &["localhost", "0.0.0.0"];
25
26/// Generates self-signed certificate and saves them into `./cert` folder
27#[inline]
28pub fn generate(names: impl Into<Vec<String>>) -> Result<()> {
29    let names = names.into();
30
31    // Validate that names are not empty
32    if names.is_empty() {
33        return Err(Error::other("Certificate names cannot be empty"));
34    }
35
36    let CertifiedKey { cert, signing_key } =
37        generate_simple_self_signed(names).map_err(|err| Error::other(format!("{:?}", err)))?;
38
39    fs::create_dir_all(DEFAULT_CERT_FOLDER)?;
40    fs::write(get_cert_path(), cert.pem())?;
41    fs::write(get_signing_key_path(), signing_key.serialize_pem())?;
42    Ok(())
43}
44
45/// Sends the message to the `stdio` that asks whether to create dev TLS certificate of not
46#[inline]
47pub fn ask_generate() -> Result<bool> {
48    print!("Dev certificate not found. Generate new one? (y/n): ");
49    std::io::stdout().flush()?;
50
51    let mut answer = String::new();
52    std::io::stdin().read_line(&mut answer)?;
53    Ok(answer.trim().eq_ignore_ascii_case("y"))
54}
55
56/// Checks whether a dev certificate exists
57#[inline]
58pub fn dev_cert_exists() -> bool {
59    get_cert_path().exists() && get_signing_key_path().exists()
60}
61
62/// Returns default path to the development TLS certificate .pem file
63#[inline]
64pub fn get_cert_path() -> PathBuf {
65    PathBuf::from(DEFAULT_CERT_FOLDER).join(DEFAULT_CERT_FILE_NAME)
66}
67
68/// Returns default path to the signin key .pem file
69#[inline]
70pub fn get_signing_key_path() -> PathBuf {
71    PathBuf::from(DEFAULT_CERT_FOLDER).join(DEFAULT_KEY_FILE_NAME)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use serial_test::serial;
78    use std::fs;
79    use std::path::Path;
80
81    // Helper to clean up before and after test
82    fn cleanup() {
83        let _ = fs::remove_dir_all(DEFAULT_CERT_FOLDER);
84    }
85
86    #[test]
87    fn it_defines_default_cert_folder_constant() {
88        assert_eq!(DEFAULT_CERT_FOLDER, "cert");
89    }
90
91    #[test]
92    fn it_defines_default_cert_file_name_constant() {
93        assert_eq!(DEFAULT_CERT_FILE_NAME, "dev-cert.pem");
94    }
95
96    #[test]
97    fn it_defines_default_key_file_name_constant() {
98        assert_eq!(DEFAULT_KEY_FILE_NAME, "dev-key.pem");
99    }
100
101    #[test]
102    fn it_defines_dev_cert_names_for_localhost() {
103        assert!(DEV_CERT_NAMES.contains(&"localhost"));
104    }
105
106    #[cfg(not(target_os = "windows"))]
107    #[test]
108    fn it_includes_zero_address_in_dev_cert_names_on_unix() {
109        assert!(DEV_CERT_NAMES.contains(&"0.0.0.0"));
110        assert_eq!(DEV_CERT_NAMES.len(), 2);
111    }
112
113    #[cfg(target_os = "windows")]
114    #[test]
115    fn it_excludes_zero_address_in_dev_cert_names_on_windows() {
116        assert!(!DEV_CERT_NAMES.contains(&"0.0.0.0"));
117        assert_eq!(DEV_CERT_NAMES.len(), 1);
118    }
119
120    #[test]
121    fn it_constructs_cert_path_correctly() {
122        let path = get_cert_path();
123
124        assert_eq!(path.file_name().unwrap(), DEFAULT_CERT_FILE_NAME);
125        assert!(path.to_string_lossy().contains(DEFAULT_CERT_FOLDER));
126    }
127
128    #[test]
129    fn it_constructs_signing_key_path_correctly() {
130        let path = get_signing_key_path();
131
132        assert_eq!(path.file_name().unwrap(), DEFAULT_KEY_FILE_NAME);
133        assert!(path.to_string_lossy().contains(DEFAULT_CERT_FOLDER));
134    }
135
136    #[test]
137    #[serial]
138    fn it_returns_false_when_dev_cert_does_not_exist() {
139        cleanup();
140
141        assert!(!dev_cert_exists());
142
143        cleanup();
144    }
145
146    #[test]
147    #[serial]
148    fn it_returns_false_when_only_cert_file_exists() {
149        cleanup();
150
151        fs::create_dir_all(DEFAULT_CERT_FOLDER).unwrap();
152        fs::write(get_cert_path(), "dummy cert").unwrap();
153
154        assert!(!dev_cert_exists());
155
156        cleanup();
157    }
158
159    #[test]
160    #[serial]
161    fn it_returns_false_when_only_key_file_exists() {
162        cleanup();
163
164        fs::create_dir_all(DEFAULT_CERT_FOLDER).unwrap();
165        fs::write(get_signing_key_path(), "dummy key").unwrap();
166
167        assert!(!dev_cert_exists());
168
169        cleanup();
170    }
171
172    #[test]
173    #[serial]
174    fn it_returns_true_when_both_cert_files_exist() {
175        cleanup();
176
177        fs::create_dir_all(DEFAULT_CERT_FOLDER).unwrap();
178        fs::write(get_cert_path(), "dummy cert").unwrap();
179        fs::write(get_signing_key_path(), "dummy key").unwrap();
180
181        assert!(dev_cert_exists());
182
183        cleanup();
184    }
185
186    #[test]
187    #[serial]
188    fn it_generates_certificate_with_single_name() {
189        cleanup();
190
191        let result = generate(vec!["test.local".to_string()]);
192
193        assert!(result.is_ok());
194        assert!(get_cert_path().exists());
195        assert!(get_signing_key_path().exists());
196
197        cleanup();
198    }
199
200    #[test]
201    #[serial]
202    fn it_generates_certificate_with_multiple_names() {
203        cleanup();
204
205        let names = vec![
206            "localhost".to_string(),
207            "127.0.0.1".to_string(),
208            "test.local".to_string(),
209        ];
210
211        let result = generate(names);
212
213        assert!(result.is_ok());
214        assert!(get_cert_path().exists());
215        assert!(get_signing_key_path().exists());
216
217        cleanup();
218    }
219
220    #[test]
221    #[serial]
222    fn it_creates_cert_folder_if_not_exists() {
223        cleanup();
224
225        let result = generate(vec!["localhost".to_string()]);
226
227        assert!(result.is_ok());
228        assert!(Path::new(DEFAULT_CERT_FOLDER).exists());
229
230        cleanup();
231    }
232
233    #[test]
234    #[serial]
235    fn it_writes_pem_formatted_certificate() {
236        cleanup();
237
238        generate(vec!["localhost".to_string()]).unwrap();
239
240        let cert_content = fs::read_to_string(get_cert_path()).unwrap();
241
242        assert!(cert_content.contains("-----BEGIN CERTIFICATE-----"));
243        assert!(cert_content.contains("-----END CERTIFICATE-----"));
244
245        cleanup();
246    }
247
248    #[test]
249    #[serial]
250    fn it_writes_pem_formatted_signing_key() {
251        cleanup();
252
253        generate(vec!["localhost".to_string()]).unwrap();
254
255        let key_content = fs::read_to_string(get_signing_key_path()).unwrap();
256
257        assert!(
258            key_content.contains("-----BEGIN PRIVATE KEY-----")
259                || key_content.contains("-----BEGIN RSA PRIVATE KEY-----")
260        );
261        assert!(
262            key_content.contains("-----END PRIVATE KEY-----")
263                || key_content.contains("-----END RSA PRIVATE KEY-----")
264        );
265
266        cleanup();
267    }
268
269    #[test]
270    #[serial]
271    fn it_overwrites_existing_certificates() {
272        cleanup();
273
274        generate(vec!["first.local".to_string()]).unwrap();
275        let first_cert = fs::read_to_string(get_cert_path()).unwrap();
276
277        std::thread::sleep(std::time::Duration::from_millis(10));
278
279        generate(vec!["second.local".to_string()]).unwrap();
280        let second_cert = fs::read_to_string(get_cert_path()).unwrap();
281
282        assert_ne!(first_cert, second_cert);
283
284        cleanup();
285    }
286
287    #[test]
288    #[serial]
289    fn it_generates_valid_certificate_structure() {
290        cleanup();
291
292        let result = generate(vec!["localhost".to_string()]);
293
294        assert!(result.is_ok());
295
296        let cert_content = fs::read_to_string(get_cert_path()).unwrap();
297        let key_content = fs::read_to_string(get_signing_key_path()).unwrap();
298
299        assert!(!cert_content.is_empty());
300        assert!(!key_content.is_empty());
301
302        assert!(cert_content.lines().count() > 2);
303        assert!(key_content.lines().count() > 2);
304
305        cleanup();
306    }
307
308    #[test]
309    #[serial]
310    fn it_handles_empty_names_vector() {
311        cleanup();
312
313        let result = generate(Vec::<String>::new());
314        assert!(result.is_err());
315
316        cleanup();
317    }
318
319    #[test]
320    #[serial]
321    fn it_generates_certificate_with_default_names() {
322        cleanup();
323
324        let names: Vec<String> = DEV_CERT_NAMES.iter().map(|s| s.to_string()).collect();
325        let result = generate(names);
326
327        assert!(result.is_ok());
328        assert!(dev_cert_exists());
329
330        cleanup();
331    }
332
333    #[test]
334    fn it_constructs_paths_with_correct_separators() {
335        let cert_path = get_cert_path();
336        let key_path = get_signing_key_path();
337
338        assert!(cert_path.is_relative());
339        assert!(key_path.is_relative());
340
341        let cert_components: Vec<_> = cert_path.components().collect();
342        let key_components: Vec<_> = key_path.components().collect();
343
344        assert_eq!(cert_components.len(), 2);
345        assert_eq!(key_components.len(), 2);
346    }
347
348    #[test]
349    #[serial]
350    fn it_generates_different_certificates_for_different_names() {
351        cleanup();
352
353        generate(vec!["name1.local".to_string()]).unwrap();
354        let cert1 = fs::read(get_cert_path()).unwrap();
355
356        std::thread::sleep(std::time::Duration::from_millis(10));
357
358        generate(vec!["name2.local".to_string()]).unwrap();
359        let cert2 = fs::read(get_cert_path()).unwrap();
360
361        assert_ne!(cert1, cert2);
362
363        cleanup();
364    }
365
366    #[test]
367    #[serial]
368    fn it_handles_special_characters_in_names() {
369        cleanup();
370
371        let names = vec!["test-app.local".to_string(), "my_service.dev".to_string()];
372
373        let result = generate(names);
374
375        assert!(result.is_ok());
376        assert!(dev_cert_exists());
377
378        cleanup();
379    }
380
381    #[test]
382    #[serial]
383    fn it_verifies_cert_folder_is_created_before_files() {
384        cleanup();
385
386        assert!(!Path::new(DEFAULT_CERT_FOLDER).exists());
387
388        generate(vec!["localhost".to_string()]).unwrap();
389
390        assert!(Path::new(DEFAULT_CERT_FOLDER).exists());
391        assert!(Path::new(DEFAULT_CERT_FOLDER).is_dir());
392
393        cleanup();
394    }
395}