1use colored::Colorize;
2use shadow_crypt_core::{
3 profile::SecurityProfile,
4 progress::ProgressCounter,
5 report::{DecryptionReport, EncryptionReport},
6 v3::key::KeyDerivationParams,
7};
8
9use crate::{errors::WorkflowError, listing::file::FileInfoList};
10
11pub fn display_progress(counter: &ProgressCounter) {
12 println!(
13 "Processed file {} of {}",
14 counter.get_current(),
15 counter.get_total()
16 );
17}
18
19pub fn display_success(message: &str) {
20 println!("{} {}", "✓".green().bold(), message);
21}
22
23pub fn display_error(error: &WorkflowError) {
24 eprintln!("{} {}", "✗".red().bold(), error);
25}
26
27pub fn display_warning(message: &str) {
28 eprintln!("{} {}", "!".yellow().bold(), message);
29}
30
31pub fn display_profiles() {
35 println!("{}", "Security Profiles (Argon2id key derivation)".bold());
36 println!();
37
38 let profiles = [
39 (
40 SecurityProfile::Standard,
41 "standard",
42 "(default)",
43 "OWASP Password Storage Cheat Sheet recommended configuration,\n\
44 using the highest-memory option of the equivalent set.",
45 ),
46 (
47 SecurityProfile::Paranoid,
48 "paranoid",
49 "",
50 "Maximum-cost derivation for high-value archives.\n\
51 Needs 1 GiB of free RAM and takes seconds per file.",
52 ),
53 ];
56
57 for (profile, name, tag, description) in profiles {
58 let params = KeyDerivationParams::from(profile);
59 println!(" {} {}", name.bold().cyan(), tag.dimmed());
60 println!(
61 " memory {} MiB, iterations {}, parallelism {}, key size {} bytes",
62 params.memory_cost / 1024,
63 params.time_cost,
64 params.parallelism,
65 params.key_size
66 );
67 for line in description.lines() {
68 println!(" {}", line.trim().dimmed());
69 }
70 println!();
71 }
72
73 println!("Files record their parameters in the header, so any profile decrypts");
74 println!("with any build of this tool.");
75}
76
77pub fn display_encryption_success(report: &EncryptionReport) {
78 let msg = format!(
79 "Encrypted '{}' -> '{}' in {:#?} using {}",
80 report.input_filename, report.output_filename, report.duration, report.algorithm
81 );
82 display_success(&msg);
83 if report.original_deleted {
84 println!(" Deleted original '{}'.", report.input_filename);
85 } else {
86 println!(
87 " Note: '{}' was not deleted — remove it manually if it is no longer needed.",
88 report.input_filename
89 );
90 }
91}
92
93pub fn display_decryption_success(report: &DecryptionReport) {
94 let msg = format!(
95 "Decrypted '{}' -> '{}' in {:#?} using {}",
96 report.input_filename, report.output_filename, report.duration, report.algorithm
97 );
98 display_success(&msg);
99}
100
101pub fn display_file_info_list(info_list: &FileInfoList, names_requested: bool) {
105 if info_list.items.is_empty() {
106 println!("{}", "No shadow files found.".yellow());
107 return;
108 }
109
110 let mut sorted_items = info_list.items.clone();
112 sorted_items.sort_by(|a, b| match (&a.original_filename, &b.original_filename) {
113 (Some(name_a), Some(name_b)) => name_a.as_str().cmp(name_b.as_str()),
114 (Some(_), None) => std::cmp::Ordering::Less,
115 (None, Some(_)) => std::cmp::Ordering::Greater,
116 (None, None) => a.obfuscated_filename.cmp(&b.obfuscated_filename),
117 });
118
119 println!("{}", "Shadow Files Listing".bold().underline());
121 println!();
122
123 if names_requested {
125 println!(
126 "{:<30} {:<30} {:<10} {:<10}",
127 "Original Filename".bold(),
128 "Obfuscated Filename".bold(),
129 "Version".bold(),
130 "Size".bold()
131 );
132 } else {
133 println!(
134 "{:<30} {:<10} {:<10}",
135 "Obfuscated Filename".bold(),
136 "Version".bold(),
137 "Size".bold()
138 );
139 }
140 println!("{}", "─".repeat(80).dimmed());
141
142 for info in &sorted_items {
144 let obfuscated = &info.obfuscated_filename;
145 let version = info.version.as_str().cyan();
146 let size = format_size(info.size).blue();
147
148 if names_requested {
149 let original = match &info.original_filename {
150 Some(name) => name.as_str().green(),
151 None => "(wrong password)".red(),
152 };
153 println!(
154 "{:<30} {:<30} {:<10} {:<10}",
155 truncate_string(&original, 28),
156 truncate_string(obfuscated, 28),
157 version,
158 size
159 );
160 } else {
161 println!(
162 "{:<30} {:<10} {:<10}",
163 truncate_string(obfuscated, 28),
164 version,
165 size
166 );
167 }
168 }
169
170 println!();
171 println!("{} files found", info_list.items.len().to_string().bold());
172
173 if names_requested {
174 let failed = sorted_items
175 .iter()
176 .filter(|i| i.original_filename.is_none())
177 .count();
178 if failed == sorted_items.len() {
179 println!(
180 "{}",
181 "No filenames could be decrypted — wrong password?".yellow()
182 );
183 } else if failed > 0 {
184 println!(
185 "{}",
186 format!(
187 "{} filename{} could not be decrypted — encrypted with a different password?",
188 failed,
189 if failed == 1 { "" } else { "s" }
190 )
191 .yellow()
192 );
193 }
194 }
195}
196
197pub fn display_file_info_list_json(info_list: &FileInfoList) {
201 println!("[");
202 for (i, info) in info_list.items.iter().enumerate() {
203 let original = match &info.original_filename {
204 Some(name) => format!("\"{}\"", json_escape(name.as_str())),
205 None => "null".to_string(),
206 };
207 let comma = if i + 1 < info_list.items.len() {
208 ","
209 } else {
210 ""
211 };
212 println!(
213 " {{\"obfuscated_filename\":\"{}\",\"version\":\"{}\",\"size\":{},\"original_filename\":{}}}{}",
214 json_escape(&info.obfuscated_filename),
215 info.version.as_str(),
216 info.size,
217 original,
218 comma
219 );
220 }
221 println!("]");
222}
223
224fn json_escape(s: &str) -> String {
225 let mut out = String::with_capacity(s.len());
226 for c in s.chars() {
227 match c {
228 '"' => out.push_str("\\\""),
229 '\\' => out.push_str("\\\\"),
230 '\n' => out.push_str("\\n"),
231 '\r' => out.push_str("\\r"),
232 '\t' => out.push_str("\\t"),
233 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
234 c => out.push(c),
235 }
236 }
237 out
238}
239
240fn format_size(bytes: u64) -> String {
241 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
242 let mut size = bytes as f64;
243 let mut unit_index = 0;
244
245 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
246 size /= 1024.0;
247 unit_index += 1;
248 }
249
250 if unit_index == 0 {
251 format!("{} {}", bytes, UNITS[0])
252 } else {
253 format!("{:.1} {}", size, UNITS[unit_index])
254 }
255}
256
257fn truncate_string(s: &str, max_len: usize) -> String {
258 if s.len() <= max_len {
259 s.to_string()
260 } else {
261 format!("{}...", &s[..max_len.saturating_sub(3)])
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_json_escape() {
271 assert_eq!(json_escape("plain.txt"), "plain.txt");
272 assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c");
273 assert_eq!(json_escape("line\nbreak\ttab"), "line\\nbreak\\ttab");
274 assert_eq!(json_escape("bell\u{07}"), "bell\\u0007");
275 assert_eq!(json_escape("unicode café 日本"), "unicode café 日本");
276 }
277}