Skip to main content

shadow_crypt_shell/
ui.rs

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
31/// Prints every security profile with its Argon2id parameters (as written by
32/// the current format version), so the costs are inspectable without reading
33/// code.
34pub 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        // The test profile exists but is deliberately not advertised here
54        // or in --help: it is insecure and for automated testing only.
55    ];
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
101/// Displays the listing. `names_requested` says whether original filenames
102/// were decrypted (a password was provided); without it only the plaintext
103/// header metadata is shown.
104pub 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    // Sort the items: files with original names first (alphabetically), then files without
111    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    // Print header
120    println!("{}", "Shadow Files Listing".bold().underline());
121    println!();
122
123    // Column headers
124    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    // Print each file info
143    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
197/// Prints the listing as a JSON array on stdout, for scripting.
198/// `original_filename` is null when names were not requested or a name
199/// could not be decrypted.
200pub 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}