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