Skip to main content

shadow_crypt_shell/listing/
workflow.rs

1use std::sync::Arc;
2
3use rayon::prelude::*;
4use shadow_crypt_core::{file::ContentKind, memory::SecureString, vault::ParsedFile};
5
6use crate::{
7    errors::WorkflowResult,
8    kdf::derive_untrusted_key,
9    listing::{
10        file::{FileInfoList, ListingInput, ShadowFile, ShadowFileInfo},
11        file_ops::{load_file_header_bytes, scan_directory_for_shadow_files},
12    },
13    ui,
14};
15
16pub fn run_workflow(input: ListingInput) -> WorkflowResult<()> {
17    let shadow_files: Vec<ShadowFile> = scan_directory_for_shadow_files(&input.work_dir)?;
18    let names_requested = input.password.is_some();
19    let password = Arc::new(input.password);
20
21    // Process files in parallel using rayon. Without a password no key
22    // derivation runs at all; only the plaintext header metadata is shown.
23    let file_infos: Vec<ShadowFileInfo> = shadow_files
24        .par_iter()
25        .map(|shadow_file| get_shadow_file_info(shadow_file, password.as_ref().as_ref()))
26        .filter_map(Result::ok)
27        .collect();
28
29    let info_list: FileInfoList = FileInfoList::new(file_infos);
30
31    if input.json {
32        ui::display_file_info_list_json(&info_list);
33    } else {
34        ui::display_file_info_list(&info_list, names_requested);
35    }
36    Ok(())
37}
38
39/// Version-agnostic: ParsedFile dispatches to the file's own format version.
40fn decipher_original_filename(
41    header_bytes: &[u8],
42    password: &SecureString,
43) -> Option<SecureString> {
44    let parsed = ParsedFile::parse(header_bytes).ok()?;
45    let key = derive_untrusted_key(&parsed, password).ok()?;
46    let metadata = parsed.decrypt_metadata(&key).ok()?;
47
48    // Archives are shown with a trailing '/' to mark them as directory trees.
49    let mut name = metadata.filename().as_str().to_string();
50    if metadata.kind() == ContentKind::Archive {
51        name.push('/');
52    }
53    Some(SecureString::new(name))
54}
55
56fn get_shadow_file_info(
57    shadow_file: &ShadowFile,
58    password: Option<&SecureString>,
59) -> WorkflowResult<ShadowFileInfo> {
60    let original_filename: Option<SecureString> = match password {
61        Some(password) => {
62            let header_bytes = load_file_header_bytes(shadow_file)?;
63            decipher_original_filename(header_bytes.as_slice(), password)
64        }
65        None => None,
66    };
67
68    Ok(ShadowFileInfo::new(
69        original_filename,
70        shadow_file.filename.clone(),
71        shadow_file.version,
72        shadow_file.size,
73    ))
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use shadow_crypt_core::{profile::SecurityProfile, v1, v2};
80
81    fn build_v1_header_bytes(password: &str, original_filename: &str) -> Vec<u8> {
82        let salt = [0u8; 16];
83        let kdf_params = v1::key::KeyDerivationParams::from(SecurityProfile::Test);
84        let filename_nonce = [0u8; 24];
85
86        let (key, _) = kdf_params.derive_key(password.as_bytes(), &salt).unwrap();
87        let (filename_ciphertext, _) = v1::crypt::encrypt_bytes(
88            original_filename.as_bytes(),
89            key.as_bytes(),
90            &filename_nonce,
91        )
92        .unwrap();
93
94        let header = v1::header::FileHeader::new(
95            salt,
96            kdf_params,
97            [0u8; 24], // content_nonce, not used
98            filename_nonce,
99            filename_ciphertext,
100        );
101        header.serialize()
102    }
103
104    fn build_v2_header_bytes(password: &str, original_filename: &str) -> Vec<u8> {
105        let salt = [0u8; 16];
106        let kdf_params = v2::key::KeyDerivationParams::from(SecurityProfile::Test);
107        let content_nonce = [1u8; 24];
108        let filename_nonce = [0u8; 24];
109
110        let (key, _) = kdf_params.derive_key(password.as_bytes(), &salt).unwrap();
111        let binding =
112            v2::header::HeaderBinding::new(&salt, &kdf_params, &content_nonce, &filename_nonce);
113        let (filename_ciphertext, _) = v2::crypt::encrypt_bytes(
114            original_filename.as_bytes(),
115            key.as_bytes(),
116            &filename_nonce,
117            &binding.aad(v2::header::AadPurpose::Filename),
118        )
119        .unwrap();
120
121        let header = v2::header::FileHeader::new(
122            salt,
123            kdf_params,
124            content_nonce,
125            filename_nonce,
126            filename_ciphertext,
127        )
128        .unwrap();
129        header.serialize()
130    }
131
132    #[test]
133    fn test_decipher_original_filename_correct_password_v1() {
134        let header_bytes = build_v1_header_bytes("testpassword", "test.txt");
135        let password = SecureString::new("testpassword".to_string());
136
137        let result = decipher_original_filename(&header_bytes, &password);
138        assert_eq!(result.unwrap().as_str(), "test.txt");
139    }
140
141    #[test]
142    fn test_decipher_original_filename_wrong_password_v1() {
143        let header_bytes = build_v1_header_bytes("testpassword", "test.txt");
144        let password = SecureString::new("wrongpassword".to_string());
145
146        let result = decipher_original_filename(&header_bytes, &password);
147        assert!(result.is_none());
148    }
149
150    #[test]
151    fn test_decipher_original_filename_correct_password_v2() {
152        let header_bytes = build_v2_header_bytes("testpassword", "test.txt");
153        let password = SecureString::new("testpassword".to_string());
154
155        let result = decipher_original_filename(&header_bytes, &password);
156        assert_eq!(result.unwrap().as_str(), "test.txt");
157    }
158
159    #[test]
160    fn test_decipher_original_filename_wrong_password_v2() {
161        let header_bytes = build_v2_header_bytes("testpassword", "test.txt");
162        let password = SecureString::new("wrongpassword".to_string());
163
164        let result = decipher_original_filename(&header_bytes, &password);
165        assert!(result.is_none());
166    }
167
168    #[test]
169    fn test_decipher_rejects_oversized_kdf_params_without_deriving() {
170        // A crafted header claiming an enormous memory cost must be rejected
171        // before any key derivation is attempted. If validation were missing,
172        // this test would attempt a multi-terabyte allocation.
173        let kdf_params = v1::key::KeyDerivationParams::new(u32::MAX, u32::MAX, u32::MAX, u8::MAX);
174        let header =
175            v1::header::FileHeader::new([0u8; 16], kdf_params, [0u8; 24], [0u8; 24], vec![1, 2, 3]);
176        let header_bytes = header.serialize();
177
178        let password = SecureString::new("testpassword".to_string());
179        let result = decipher_original_filename(&header_bytes, &password);
180
181        assert!(result.is_none());
182    }
183}