git_simple_encrypt/utils/
mod.rs1mod 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#[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
34pub 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
46pub 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 let result = Zeroizing::new(trimmed.to_string());
62 zeroize::Zeroize::zeroize(&mut password);
63 Ok(result)
64}
65
66pub 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
118const REPORT_LIST_LIMIT: usize = 10;
122
123pub 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
148pub 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
172pub 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 return Ok(false);
181 }
182 Ok(&header_bytes[0..5] == MAGIC && is_encrypted_version(header_bytes[5]))
183}
184
185#[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}