pars_core/operation/
generate.rs

1use std::fs;
2use std::io::{BufRead, Read, Write};
3use std::path::Path;
4
5use anyhow::{anyhow, Result};
6use passwords::PasswordGenerator;
7use secrecy::{ExposeSecret, SecretString};
8
9use crate::pgp::PGPClient;
10use crate::util::fs_util;
11use crate::util::fs_util::{
12    backup_encrypted_file, create_or_overwrite, get_dir_gpg_id_content, path_attack_check,
13    path_to_str, restore_backup_file,
14};
15
16pub struct IOStreams<'a, I, O, E>
17where
18    I: Read + BufRead,
19    O: Write,
20    E: Write,
21{
22    pub in_s: &'a mut I,
23    pub out_s: &'a mut O,
24    pub err_s: &'a mut E,
25}
26
27pub struct PasswdGenerateConfig {
28    pub no_symbols: bool,
29    pub in_place: bool,
30    pub force: bool,
31    pub pass_length: usize,
32    pub extension: String,
33    pub pgp_executable: String,
34}
35
36pub fn generate_io<I, O, E>(
37    root: &Path,
38    pass_name: &str,
39    gen_cfg: &PasswdGenerateConfig,
40    io_streams: &mut IOStreams<I, O, E>,
41) -> Result<SecretString>
42where
43    I: Read + BufRead,
44    O: Write,
45    E: Write,
46{
47    let pass_path = root.join(format!("{}.{}", pass_name, gen_cfg.extension));
48
49    path_attack_check(root, &pass_path)?;
50
51    if gen_cfg.in_place && gen_cfg.force {
52        let err_msg = "Cannot use both [--in-place] and [--force]";
53        writeln!(io_streams.err_s, "{err_msg}")?;
54        return Err(anyhow!(err_msg));
55    }
56
57    if pass_path.exists()
58        && !gen_cfg.force
59        && !gen_cfg.in_place
60        && !fs_util::prompt_overwrite(io_streams.in_s, io_streams.err_s, pass_name)?
61    {
62        writeln!(io_streams.out_s, "Operation cancelled.")?;
63        return Ok(SecretString::new("".to_string().into()));
64    }
65
66    let pg = PasswordGenerator::new()
67        .length(gen_cfg.pass_length)
68        .numbers(true)
69        .lowercase_letters(true)
70        .uppercase_letters(true)
71        .symbols(!gen_cfg.no_symbols)
72        .spaces(false)
73        .exclude_similar_characters(true)
74        .strict(true);
75
76    let password = SecretString::new(pg.generate_one().map_err(|e| anyhow!(e))?.into());
77
78    // Get the appropriate key fingerprints for this path
79    let keys_fpr = get_dir_gpg_id_content(root, &pass_path)?;
80    let client = PGPClient::new(&gen_cfg.pgp_executable, &keys_fpr)?;
81
82    if gen_cfg.in_place && pass_path.exists() {
83        let existing = client.decrypt_stdin(root, path_to_str(&pass_path)?)?;
84        let mut content = existing.expose_secret().lines().collect::<Vec<_>>();
85
86        if !content.is_empty() {
87            content[0] = password.expose_secret();
88
89            let backup = backup_encrypted_file(&pass_path)?;
90            match client.encrypt(&content.join("\n"), path_to_str(&pass_path)?) {
91                Ok(_) => {
92                    fs::remove_file(&backup)?;
93                }
94                Err(e) => {
95                    restore_backup_file(&backup)?;
96                    return Err(e);
97                }
98            }
99        }
100    } else {
101        if let Some(parent) = pass_path.parent() {
102            fs::create_dir_all(parent)?;
103        }
104
105        create_or_overwrite(&client, &pass_path, &password)?;
106    }
107
108    writeln!(io_streams.out_s, "Generated password for '{pass_name}' saved")?;
109
110    Ok(password)
111}
112
113#[cfg(test)]
114mod tests {
115
116    use std::io::{stderr, stdout, BufReader};
117    use std::thread;
118
119    use os_pipe::pipe;
120    use pretty_assertions::assert_eq;
121    use serial_test::serial;
122
123    use super::*;
124    use crate::pgp::key_management::key_gen_batch;
125    use crate::util::defer::cleanup;
126    use crate::util::test_util::*;
127
128    fn setup_test_client(root: &Path) -> PGPClient {
129        key_gen_batch(&get_test_executable(), &gpg_key_gen_example_batch()).unwrap();
130        let test_client = PGPClient::new(get_test_executable(), &[&get_test_email()]).unwrap();
131        test_client.key_edit_batch(&gpg_key_edit_example_batch()).unwrap();
132        write_gpg_id(root, &test_client.get_keys_fpr());
133        test_client
134    }
135
136    #[test]
137    #[serial]
138    #[ignore = "need run interactively"]
139    fn basic_password_generation() {
140        let executable = get_test_executable();
141        let email = get_test_email();
142        let (_tmp_dir, root) = gen_unique_temp_dir();
143
144        cleanup!(
145            {
146                let (stdin, stdin_w) = pipe().unwrap();
147                let mut stdin = BufReader::new(stdin);
148                let mut stdout = stdout().lock();
149                let mut stderr = stderr().lock();
150                let test_client = setup_test_client(&root);
151
152                let mut config = PasswdGenerateConfig {
153                    no_symbols: false,
154                    in_place: false,
155                    force: false,
156                    pass_length: 16,
157                    extension: "gpg".to_string(),
158                    pgp_executable: executable.clone(),
159                };
160
161                let mut io_streams =
162                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
163
164                let password = generate_io(&root, "test1", &config, &mut io_streams).unwrap();
165
166                assert_eq!(password.expose_secret().len(), 16);
167                assert!(root.join("test1.gpg").exists());
168                let secret = test_client.decrypt_stdin(&root, "test1.gpg").unwrap();
169                assert_eq!(secret.expose_secret(), password.expose_secret());
170
171                // Now test interactive overwrite
172                config.pass_length = 114;
173                thread::spawn(move || {
174                    let mut stdin = stdin_w;
175                    stdin.write_all(b"n").unwrap();
176                });
177                let original_passwd = password;
178                let password = generate_io(&root, "test1", &config, &mut io_streams).unwrap();
179                let secret = test_client.decrypt_stdin(&root, "test1.gpg").unwrap();
180                assert_eq!(password.expose_secret(), "");
181                assert_eq!(secret.expose_secret(), original_passwd.expose_secret());
182
183                let (stdin, stdin_w) = pipe().unwrap();
184                let mut stdin = BufReader::new(stdin);
185                let mut io_streams =
186                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
187                thread::spawn(move || {
188                    let mut stdin = stdin_w;
189                    stdin.write_all(b"y").unwrap();
190                });
191                let password = generate_io(&root, "test1", &config, &mut io_streams).unwrap();
192                let secret = test_client.decrypt_stdin(&root, "test1.gpg").unwrap();
193                assert_eq!(secret.expose_secret(), password.expose_secret());
194                assert_eq!(password.expose_secret().len(), 114);
195            },
196            {
197                clean_up_test_key(&executable, &[&email]).unwrap();
198            }
199        );
200    }
201
202    #[test]
203    #[serial]
204    #[ignore = "need run interactively"]
205    fn inplace_generation() {
206        let executable = get_test_executable();
207        let email = get_test_email();
208        let (_tmp_dir, root) = gen_unique_temp_dir();
209
210        cleanup!(
211            {
212                let (stdin, _) = pipe().unwrap();
213                let mut stdin = BufReader::new(stdin);
214                let mut stdout = stdout().lock();
215                let mut stderr = stderr().lock();
216
217                let test_client = setup_test_client(&root);
218                test_client
219                    .encrypt(
220                        "existing\npassword\nfor super earth",
221                        path_to_str(&root.join("test2.gpg")).unwrap(),
222                    )
223                    .unwrap();
224
225                let config = PasswdGenerateConfig {
226                    no_symbols: false,
227                    in_place: true,
228                    force: false,
229                    pass_length: 12,
230                    extension: "gpg".to_string(),
231                    pgp_executable: executable.clone(),
232                };
233
234                let mut io_streams =
235                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
236
237                let password = generate_io(&root, "test2", &config, &mut io_streams).unwrap();
238
239                let content = test_client.decrypt_stdin(&root, "test2.gpg").unwrap();
240                let lines: Vec<&str> = content.expose_secret().lines().collect();
241                assert_eq!(lines[0], password.expose_secret());
242                assert_eq!(password.expose_secret().len(), 12);
243                assert_eq!(lines[1], "password");
244                assert_eq!(lines[2], "for super earth");
245            },
246            {
247                clean_up_test_key(&executable, &[&email]).unwrap();
248            }
249        );
250    }
251
252    #[test]
253    #[serial]
254    #[ignore = "need run interactively"]
255    fn force_overwrite() {
256        let executable = get_test_executable();
257        let email = get_test_email();
258        let (_tmp_dir, root) = gen_unique_temp_dir();
259
260        cleanup!(
261            {
262                let (stdin, _) = pipe().unwrap();
263                let mut stdin = BufReader::new(stdin);
264                let mut stdout = stdout().lock();
265                let mut stderr = stderr().lock();
266
267                let test_client = setup_test_client(&root);
268                test_client
269                    .encrypt("old_password", path_to_str(&root.join("test3.gpg")).unwrap())
270                    .unwrap();
271
272                let config = PasswdGenerateConfig {
273                    no_symbols: false,
274                    in_place: false,
275                    force: true,
276                    pass_length: 8,
277                    extension: "gpg".to_string(),
278                    pgp_executable: executable.clone(),
279                };
280
281                let mut io_streams =
282                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
283
284                let password = generate_io(&root, "test3", &config, &mut io_streams).unwrap();
285
286                assert_eq!(password.expose_secret().len(), 8);
287                let content = test_client.decrypt_stdin(&root, "test3.gpg").unwrap();
288                assert_eq!(content.expose_secret(), password.expose_secret());
289            },
290            {
291                clean_up_test_key(&executable, &[&email]).unwrap();
292            }
293        );
294    }
295
296    #[test]
297    #[serial]
298    #[ignore = "need run interactively"]
299    fn no_symbols() {
300        let executable = get_test_executable();
301        let email = get_test_email();
302        let (_tmp_dir, root) = gen_unique_temp_dir();
303
304        cleanup!(
305            {
306                let (stdin, _) = pipe().unwrap();
307                let mut stdin = BufReader::new(stdin);
308                let mut stdout = stdout().lock();
309                let mut stderr = stderr().lock();
310                let test_client = setup_test_client(&root);
311
312                let config = PasswdGenerateConfig {
313                    no_symbols: true,
314                    in_place: false,
315                    force: false,
316                    pass_length: 10,
317                    extension: "gpg".to_string(),
318                    pgp_executable: executable.clone(),
319                };
320
321                let mut io_streams =
322                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
323
324                let password = generate_io(&root, "test4", &config, &mut io_streams).unwrap();
325
326                assert!(!password.expose_secret().contains(|c: char| !c.is_alphanumeric()));
327                let content = test_client.decrypt_stdin(&root, "test4.gpg").unwrap();
328                assert_eq!(content.expose_secret(), password.expose_secret());
329            },
330            {
331                clean_up_test_key(&executable, &[&email]).unwrap();
332            }
333        );
334    }
335
336    #[test]
337    #[serial]
338    #[ignore = "need run interactively"]
339    fn invalid_path() {
340        let executable = get_test_executable();
341        let email = get_test_email();
342        let (_tmp_dir, root) = gen_unique_temp_dir();
343
344        cleanup!(
345            {
346                let (stdin, _) = pipe().unwrap();
347                let mut stdin = BufReader::new(stdin);
348                let mut stdout = stdout().lock();
349                let mut stderr = stderr().lock();
350
351                let config = PasswdGenerateConfig {
352                    no_symbols: false,
353                    in_place: false,
354                    force: false,
355                    pass_length: 16,
356                    extension: "gpg".to_string(),
357                    pgp_executable: executable.clone(),
358                };
359
360                let mut io_streams =
361                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
362
363                let result = generate_io(&root, "../outside", &config, &mut io_streams);
364
365                assert!(result.is_err());
366            },
367            {
368                clean_up_test_key(&executable, &[&email]).unwrap();
369            }
370        );
371    }
372
373    #[test]
374    #[serial]
375    #[ignore = "need run interactively"]
376    fn invalid_flag() {
377        let executable = get_test_executable();
378        let email = get_test_email();
379        let (_tmp_dir, root) = gen_unique_temp_dir();
380
381        cleanup!(
382            {
383                let (stdin, _) = pipe().unwrap();
384                let mut stdin = BufReader::new(stdin);
385                let mut stdout = stdout().lock();
386                let mut stderr = stderr().lock();
387
388                let config = PasswdGenerateConfig {
389                    no_symbols: false,
390                    in_place: true,
391                    force: true,
392                    pass_length: 16,
393                    extension: "gpg".to_string(),
394                    pgp_executable: executable.clone(),
395                };
396
397                let mut io_streams =
398                    IOStreams { in_s: &mut stdin, out_s: &mut stdout, err_s: &mut stderr };
399
400                let result = generate_io(&root, "test5", &config, &mut io_streams);
401
402                assert!(result.is_err());
403            },
404            {
405                clean_up_test_key(&executable, &[&email]).unwrap();
406            }
407        );
408    }
409}