Skip to main content

git_simple_encrypt/utils/
mod.rs

1mod progress;
2pub(crate) mod style;
3
4use std::{
5    fs,
6    io::{Read, Write},
7    path::{Path, PathBuf},
8    sync::mpsc,
9};
10
11use ignore::{WalkBuilder, WalkState};
12pub use progress::Progress;
13use tempfile::NamedTempFile;
14use zeroize::Zeroizing;
15
16use crate::{
17    crypt::{HEADER_LEN, MAGIC, is_encrypted_version},
18    error::{Error, Result},
19    utils::style::Colorize,
20};
21
22/// Format a byte array into a hex string
23#[allow(dead_code)]
24#[cfg(any(test, debug_assertions))]
25#[must_use]
26pub fn format_hex(value: &[u8]) -> String {
27    use std::fmt::Write;
28    value.iter().fold(String::new(), |mut output, b| {
29        let _ = write!(output, "{b:02x}");
30        output
31    })
32}
33
34/// Atomically write `data` to `path` by writing to a temp file first, then
35/// renaming. This prevents partial writes from corrupting the target file.
36pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
37    let parent = path.parent().unwrap_or_else(|| Path::new("."));
38    let mut temp_file = NamedTempFile::new_in(parent)?;
39    temp_file.write_all(data)?;
40    temp_file
41        .persist(path)
42        .map_err(|e| Error::AtomicPersist(path.to_path_buf(), e.to_string()))?;
43    Ok(())
44}
45
46/// Prompt the user for a password.
47///
48/// Returns an empty-password error if the user enters only whitespace. The
49/// returned string is wrapped in [`Zeroizing`] so the plaintext is scrubbed
50/// from memory on drop.
51pub fn prompt_password(prompt: &str) -> Result<Zeroizing<String>> {
52    print!("{prompt}");
53    std::io::stdout().flush()?;
54    let mut password = String::new();
55    std::io::stdin().read_line(&mut password)?;
56    let trimmed = password.trim();
57    if trimmed.is_empty() {
58        return Err(Error::EmptyPassword);
59    }
60    // Scrub the raw input buffer too.
61    let result = Zeroizing::new(trimmed.to_string());
62    zeroize::Zeroize::zeroize(&mut password);
63    Ok(result)
64}
65
66/// If the given path is a file, return the file name. Otherwise, return the
67/// recursive file name in the given dir.
68pub fn list_files(
69    paths: impl IntoIterator<Item = impl AsRef<Path>>,
70    cwd: impl AsRef<Path>,
71) -> Vec<PathBuf> {
72    let mut paths_iter = paths.into_iter();
73    let cwd = cwd.as_ref();
74
75    let mut builder = if let Some(first_path) = paths_iter.next() {
76        debug_assert!(first_path.as_ref().is_relative());
77        WalkBuilder::new(cwd.join(first_path))
78    } else {
79        return Vec::new();
80    };
81
82    for p in paths_iter {
83        debug_assert!(p.as_ref().is_relative());
84        builder.add(cwd.join(p));
85    }
86
87    builder
88        .current_dir(cwd)
89        .hidden(false)
90        .git_ignore(true)
91        .ignore(true)
92        .git_global(true)
93        .git_exclude(true)
94        .follow_links(false)
95        .threads(0);
96
97    let parallel_walker: ignore::WalkParallel = builder.build_parallel();
98
99    let (tx, rx) = mpsc::channel();
100
101    parallel_walker.run(|| {
102        let tx = tx.clone();
103        Box::new(move |result| {
104            if let Ok(entry) = result
105                && let Some(file_type) = entry.file_type()
106                && file_type.is_file()
107            {
108                let _ = tx.send(entry.into_path());
109            }
110            WalkState::Continue
111        })
112    });
113
114    drop(tx);
115    rx.into_iter().collect()
116}
117
118// --- Reporting & Progress Helpers ---
119
120/// Maximum number of files to display individually before collapsing.
121const REPORT_LIST_LIMIT: usize = 10;
122
123/// Print a pre-operation report listing the target files and total count.
124/// If the list exceeds `REPORT_LIST_LIMIT`, show the first few and summarize
125/// the rest as "... and N more files".
126pub fn print_pre_report(action: &str, files: &[impl AsRef<Path>], repo_path: &Path) {
127    let count = files.len();
128    println!(
129        "\n{} {} {}",
130        action.bold(),
131        format!("({count} files)").cyan(),
132        ":".dimmed()
133    );
134
135    for f in &files[..count.min(REPORT_LIST_LIMIT)] {
136        let relative =
137            pathdiff::diff_paths(f.as_ref(), repo_path).unwrap_or_else(|| f.as_ref().to_path_buf());
138        println!("  {}", relative.display());
139    }
140
141    if count > REPORT_LIST_LIMIT {
142        let remaining = count - REPORT_LIST_LIMIT;
143        println!("  {}", format!("... and {remaining} more files").dimmed());
144    }
145    println!();
146}
147
148/// Print a post-operation summary report.
149pub fn print_post_report(action: &str, total: usize, skipped: usize, failed: usize) {
150    let succeeded = total - skipped - failed;
151    let label = format!("{action} complete").bold();
152
153    if failed > 0 {
154        println!(
155            "\n{}: {} succeeded, {} skipped, {} {}",
156            label,
157            succeeded.to_string().green(),
158            skipped.to_string().yellow(),
159            failed.to_string().red(),
160            "failed".red(),
161        );
162    } else {
163        println!(
164            "\n{}: {} succeeded, {} skipped",
165            label,
166            succeeded.to_string().green(),
167            skipped.to_string().yellow(),
168        );
169    }
170}
171
172/// Check whether a single file has a valid GITSE encrypted header.
173/// Returns an error if the file cannot be read (IO error).
174pub fn is_file_encrypted(path: &Path) -> Result<bool> {
175    let mut file = fs::File::open(path)?;
176    let mut header_bytes = [0u8; HEADER_LEN];
177    let bytes_read = file.read(&mut header_bytes)?;
178    if bytes_read < HEADER_LEN {
179        // File is smaller than the header, definitely not encrypted
180        return Ok(false);
181    }
182    Ok(&header_bytes[0..5] == MAGIC && is_encrypted_version(header_bytes[5]))
183}
184
185/// Resolve the target file list for the repo. If `paths` is empty, use the
186/// crypt list from the config; otherwise, use the given paths.
187#[must_use]
188pub fn resolve_target_files(
189    paths: &[PathBuf],
190    crypt_list: &[String],
191    repo_path: &Path,
192) -> Vec<PathBuf> {
193    if paths.is_empty() {
194        list_files(crypt_list.iter(), repo_path)
195    } else {
196        list_files(paths, repo_path)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use assert2::assert;
203    use path_absolutize::Absolutize as _;
204
205    use super::*;
206
207    #[test]
208    fn test_list_files() {
209        let paths = vec!["docs", ".gitignore", "src", "some_thing_not_exist"]
210            .into_iter()
211            .map(PathBuf::from);
212        let res = list_files(paths, ".")
213            .into_iter()
214            .map(|x| x.absolutize().unwrap().to_path_buf())
215            .collect::<Vec<_>>();
216        dbg!(&res);
217        assert!(
218            res.contains(
219                &Path::new("docs/README_zh-CN.md")
220                    .absolutize()
221                    .unwrap()
222                    .to_path_buf()
223            )
224        );
225        assert!(res.contains(&Path::new(".gitignore").absolutize().unwrap().to_path_buf()));
226        assert!(
227            res.contains(
228                &Path::new("src/utils/mod.rs")
229                    .absolutize()
230                    .unwrap()
231                    .to_path_buf()
232            )
233        );
234        assert!(!res.contains(&Path::new("docs/").absolutize().unwrap().to_path_buf()));
235    }
236
237    #[test]
238    fn test_cwd() {
239        assert_eq!(
240            list_files([".gitignore"], Path::new(".").absolutize().unwrap()),
241            vec![Path::new(".gitignore").absolutize().unwrap()]
242        );
243        assert_eq!(
244            list_files(["lib.rs"], Path::new("src").absolutize().unwrap()),
245            vec![Path::new("src/lib.rs").absolutize().unwrap()]
246        );
247    }
248}