1use std::fs::File;
37use std::io::Read as _;
38use std::path::{Path, PathBuf};
39
40use crate::error::{Error, Result};
41use crate::header::{
42 FIXED_HEADER_LEN, FixedHeader, HEADER_LEN_FIELD_OFFSET, HEADER_MAC_LEN, MAGIC_FILE,
43 SLOT_AREA_OFFSET, TLV_AREA_OFFSET, parse_limits,
44};
45use crate::session::{CredentialKind, SessionKeys};
46use crate::slot::unwrap_fek;
47use crate::tlv::{TlvSet, types};
48
49pub const MIN_PROBE_SIZE: usize = TLV_AREA_OFFSET;
57
58pub const MIN_FILE_SIZE: usize = TLV_AREA_OFFSET + HEADER_MAC_LEN;
60
61#[derive(Debug)]
63pub struct ScanHit {
64 pub path: PathBuf,
66 pub file_size: u64,
68 pub header: FixedHeader,
70 pub unlock: UnlockOutcome,
72}
73
74#[derive(Debug)]
76pub enum UnlockOutcome {
77 Unlocked {
79 credential: String,
81 kind: CredentialKind,
83 slot_index: usize,
85 filename: Option<String>,
87 plaintext_size: u64,
89 },
90 Locked,
95}
96
97impl UnlockOutcome {
98 #[must_use]
100 pub const fn is_unlocked(&self) -> bool {
101 matches!(self, Self::Unlocked { .. })
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct ScanOptions {
108 pub recursive: bool,
110 pub max_depth: usize,
112 pub follow_symlinks: bool,
114 pub extension_filter: Option<String>,
116 pub max_files: usize,
118}
119
120impl Default for ScanOptions {
121 fn default() -> Self {
122 Self {
123 recursive: true,
124 max_depth: 32,
125 follow_symlinks: false,
126 extension_filter: None,
128 max_files: 100_000,
129 }
130 }
131}
132
133impl ScanOptions {
134 #[must_use]
136 pub fn omy_only() -> Self {
137 Self {
138 extension_filter: Some(crate::FILE_EXTENSION.to_owned()),
139 ..Self::default()
140 }
141 }
142}
143
144#[derive(Debug, Default, Clone, Copy)]
146pub struct ScanStats {
147 pub files_examined: usize,
149 pub omy_found: usize,
151 pub unlocked: usize,
153 pub io_errors: usize,
155 pub malformed: usize,
157}
158
159#[derive(Debug, Default)]
161pub struct ScanResult {
162 pub hits: Vec<ScanHit>,
164 pub stats: ScanStats,
166}
167
168impl ScanResult {
169 #[must_use]
171 pub fn unlocked(&self) -> Vec<&ScanHit> {
172 self.hits.iter().filter(|h| h.unlock.is_unlocked()).collect()
173 }
174}
175
176fn read_exact_upto(f: &mut File, n: usize) -> std::io::Result<Vec<u8>> {
182 let mut buf = Vec::new();
183 f.take(n as u64).read_to_end(&mut buf)?;
184 Ok(buf)
185}
186
187pub fn read_prefix(path: &Path, n: usize) -> std::io::Result<Vec<u8>> {
197 let mut f = File::open(path)?;
198 read_exact_upto(&mut f, n)
199}
200
201pub fn read_header_area(path: &Path) -> std::io::Result<Vec<u8>> {
209 let mut f = File::open(path)?;
210 let mut buf = read_exact_upto(&mut f, MIN_PROBE_SIZE)?;
211
212 if buf.len() < FIXED_HEADER_LEN {
214 return Ok(buf);
215 }
216 if buf.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
218 return Ok(buf);
219 }
220
221 let Some(raw) = buf.get(HEADER_LEN_FIELD_OFFSET..HEADER_LEN_FIELD_OFFSET + 4) else {
225 return Ok(buf);
226 };
227 let mut le = [0u8; 4];
228 le.copy_from_slice(raw);
229 let header_len = u32::from_le_bytes(le) as usize;
230
231 if header_len > parse_limits::MAX_HEADER_LEN as usize || header_len <= buf.len() {
233 return Ok(buf);
234 }
235
236 let extra = header_len.saturating_sub(buf.len());
238 let mut rest = read_exact_upto(&mut f, extra)?;
239 buf.append(&mut rest);
240 Ok(buf)
241}
242
243pub fn probe_file(path: &Path, session: &SessionKeys) -> Result<Option<ScanHit>> {
251 let prefix = match read_header_area(path) {
252 Ok(p) => p,
253 Err(e) => {
254 return Err(Error::Io(std::io::Error::new(
255 e.kind(),
256 format!("{}: {e}", path.display()),
257 )));
258 }
259 };
260
261 if prefix.len() < MAGIC_FILE.len() || prefix.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
263 return Ok(None);
264 }
265
266 let header = FixedHeader::parse(&prefix)?;
267 let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
268
269 let creds = session.scannable_for(&header.vault_salt);
272 let slot_area =
273 prefix.get(SLOT_AREA_OFFSET..TLV_AREA_OFFSET).ok_or(Error::Truncated {
274 context: "slot area",
275 need: TLV_AREA_OFFSET,
276 got: prefix.len(),
277 })?;
278
279 for cred in &creds {
280 let keks = [cred.kek.duplicate()];
281 if let Ok((fek, _, slot_index)) =
282 unwrap_fek(slot_area, &keks, &header.file_uuid, header.cipher_id)
283 {
284 let filename = read_filename(&prefix, &header, &fek);
286 return Ok(Some(ScanHit {
287 path: path.to_path_buf(),
288 file_size,
289 unlock: UnlockOutcome::Unlocked {
290 credential: cred.label.clone(),
291 kind: cred.kind,
292 slot_index: usize::from(slot_index),
293 filename,
294 plaintext_size: header.plaintext_size,
295 },
296 header,
297 }));
298 }
299 }
300
301 Ok(Some(ScanHit { path: path.to_path_buf(), file_size, header, unlock: UnlockOutcome::Locked }))
302}
303
304fn read_filename(prefix: &[u8], header: &FixedHeader, fek: &crate::crypto::Fek) -> Option<String> {
308 let tlv_end = (header.header_len as usize).checked_sub(HEADER_MAC_LEN)?;
309 let blob = prefix.get(TLV_AREA_OFFSET..tlv_end)?;
310 let set = TlvSet::parse(blob).ok()?;
311 let plain = set.decrypt_value(types::FILENAME, fek, header.cipher_id).ok()?;
312 crate::tlv::unpad_filename(&plain).ok()
313}
314
315pub fn scan_dir(root: &Path, session: &SessionKeys, opts: &ScanOptions) -> Result<ScanResult> {
327 if !root.is_dir() {
328 return Err(Error::Io(std::io::Error::new(
329 std::io::ErrorKind::NotADirectory,
330 format!("not a directory: {}", root.display()),
331 )));
332 }
333 let mut out = ScanResult::default();
334 let mut stack = vec![(root.to_path_buf(), 0usize)];
335
336 while let Some((dir, depth)) = stack.pop() {
337 if out.stats.files_examined >= opts.max_files {
338 break;
339 }
340 let rd = match std::fs::read_dir(&dir) {
341 Ok(rd) => rd,
342 Err(_) => {
343 out.stats.io_errors = out.stats.io_errors.saturating_add(1);
345 continue;
346 }
347 };
348
349 for entry in rd.flatten() {
350 if out.stats.files_examined >= opts.max_files {
351 break;
352 }
353 let path = entry.path();
354
355 let Ok(meta) = std::fs::symlink_metadata(&path) else {
357 out.stats.io_errors = out.stats.io_errors.saturating_add(1);
358 continue;
359 };
360
361 if meta.is_symlink() && !opts.follow_symlinks {
362 continue;
363 }
364
365 let is_dir = if meta.is_symlink() {
366 path.is_dir() } else {
368 meta.is_dir()
369 };
370
371 if is_dir {
372 if opts.recursive && depth < opts.max_depth {
373 stack.push((path, depth.saturating_add(1)));
374 }
375 continue;
376 }
377
378 if let Some(want) = opts.extension_filter.as_deref() {
380 let matches = path
381 .extension()
382 .and_then(|e| e.to_str())
383 .is_some_and(|e| e.eq_ignore_ascii_case(want));
384 if !matches {
385 continue;
386 }
387 }
388
389 if meta.len() < MIN_FILE_SIZE as u64 {
391 continue;
392 }
393
394 out.stats.files_examined = out.stats.files_examined.saturating_add(1);
395
396 match probe_file(&path, session) {
397 Ok(Some(hit)) => {
398 out.stats.omy_found = out.stats.omy_found.saturating_add(1);
399 if hit.unlock.is_unlocked() {
400 out.stats.unlocked = out.stats.unlocked.saturating_add(1);
401 }
402 out.hits.push(hit);
403 }
404 Ok(None) => {} Err(Error::Io { .. }) => {
406 out.stats.io_errors = out.stats.io_errors.saturating_add(1);
407 }
408 Err(_) => {
409 out.stats.malformed = out.stats.malformed.saturating_add(1);
411 }
412 }
413 }
414 }
415
416 Ok(out)
417}
418
419pub fn scan_dirs(
425 roots: &[PathBuf],
426 session: &SessionKeys,
427 opts: &ScanOptions,
428) -> Result<ScanResult> {
429 let mut merged = ScanResult::default();
430 let mut last_err = None;
431 let mut any_ok = false;
432
433 for root in roots {
434 match scan_dir(root, session, opts) {
435 Ok(r) => {
436 any_ok = true;
437 merged.hits.extend(r.hits);
438 let s = &mut merged.stats;
439 s.files_examined = s.files_examined.saturating_add(r.stats.files_examined);
440 s.omy_found = s.omy_found.saturating_add(r.stats.omy_found);
441 s.unlocked = s.unlocked.saturating_add(r.stats.unlocked);
442 s.io_errors = s.io_errors.saturating_add(r.stats.io_errors);
443 s.malformed = s.malformed.saturating_add(r.stats.malformed);
444 }
445 Err(e) => last_err = Some(e),
446 }
447 }
448
449 if any_ok {
450 Ok(merged)
451 } else {
452 Err(last_err.unwrap_or_else(|| {
453 Error::Io(std::io::Error::other("no directories scanned"))
454 }))
455 }
456}