use std::fs::File;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::header::{
FIXED_HEADER_LEN, FixedHeader, HEADER_LEN_FIELD_OFFSET, HEADER_MAC_LEN, MAGIC_FILE,
SLOT_AREA_OFFSET, TLV_AREA_OFFSET, parse_limits,
};
use crate::session::{CredentialKind, SessionKeys};
use crate::slot::unwrap_fek;
use crate::tlv::{TlvSet, types};
pub const MIN_PROBE_SIZE: usize = TLV_AREA_OFFSET;
pub const MIN_FILE_SIZE: usize = TLV_AREA_OFFSET + HEADER_MAC_LEN;
#[derive(Debug)]
pub struct ScanHit {
pub path: PathBuf,
pub file_size: u64,
pub header: FixedHeader,
pub unlock: UnlockOutcome,
}
#[derive(Debug)]
pub enum UnlockOutcome {
Unlocked {
credential: String,
kind: CredentialKind,
slot_index: usize,
filename: Option<String>,
plaintext_size: u64,
},
Locked,
}
impl UnlockOutcome {
#[must_use]
pub const fn is_unlocked(&self) -> bool {
matches!(self, Self::Unlocked { .. })
}
}
#[derive(Debug, Clone)]
pub struct ScanOptions {
pub recursive: bool,
pub max_depth: usize,
pub follow_symlinks: bool,
pub extension_filter: Option<String>,
pub max_files: usize,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
recursive: true,
max_depth: 32,
follow_symlinks: false,
extension_filter: None,
max_files: 100_000,
}
}
}
impl ScanOptions {
#[must_use]
pub fn omy_only() -> Self {
Self {
extension_filter: Some(crate::FILE_EXTENSION.to_owned()),
..Self::default()
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ScanStats {
pub files_examined: usize,
pub omy_found: usize,
pub unlocked: usize,
pub io_errors: usize,
pub malformed: usize,
}
#[derive(Debug, Default)]
pub struct ScanResult {
pub hits: Vec<ScanHit>,
pub stats: ScanStats,
}
impl ScanResult {
#[must_use]
pub fn unlocked(&self) -> Vec<&ScanHit> {
self.hits.iter().filter(|h| h.unlock.is_unlocked()).collect()
}
}
fn read_exact_upto(f: &mut File, n: usize) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
f.take(n as u64).read_to_end(&mut buf)?;
Ok(buf)
}
pub fn read_prefix(path: &Path, n: usize) -> std::io::Result<Vec<u8>> {
let mut f = File::open(path)?;
read_exact_upto(&mut f, n)
}
pub fn read_header_area(path: &Path) -> std::io::Result<Vec<u8>> {
let mut f = File::open(path)?;
let mut buf = read_exact_upto(&mut f, MIN_PROBE_SIZE)?;
if buf.len() < FIXED_HEADER_LEN {
return Ok(buf);
}
if buf.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
return Ok(buf);
}
let Some(raw) = buf.get(HEADER_LEN_FIELD_OFFSET..HEADER_LEN_FIELD_OFFSET + 4) else {
return Ok(buf);
};
let mut le = [0u8; 4];
le.copy_from_slice(raw);
let header_len = u32::from_le_bytes(le) as usize;
if header_len > parse_limits::MAX_HEADER_LEN as usize || header_len <= buf.len() {
return Ok(buf);
}
let extra = header_len.saturating_sub(buf.len());
let mut rest = read_exact_upto(&mut f, extra)?;
buf.append(&mut rest);
Ok(buf)
}
pub fn probe_file(path: &Path, session: &SessionKeys) -> Result<Option<ScanHit>> {
let prefix = match read_header_area(path) {
Ok(p) => p,
Err(e) => {
return Err(Error::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", path.display()),
)));
}
};
if prefix.len() < MAGIC_FILE.len() || prefix.get(..MAGIC_FILE.len()) != Some(&MAGIC_FILE[..]) {
return Ok(None);
}
let header = FixedHeader::parse(&prefix)?;
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let creds = session.scannable_for(&header.vault_salt);
let slot_area =
prefix.get(SLOT_AREA_OFFSET..TLV_AREA_OFFSET).ok_or(Error::Truncated {
context: "slot area",
need: TLV_AREA_OFFSET,
got: prefix.len(),
})?;
for cred in &creds {
let keks = [cred.kek.duplicate()];
if let Ok((fek, _, slot_index)) =
unwrap_fek(slot_area, &keks, &header.file_uuid, header.cipher_id)
{
let filename = read_filename(&prefix, &header, &fek);
return Ok(Some(ScanHit {
path: path.to_path_buf(),
file_size,
unlock: UnlockOutcome::Unlocked {
credential: cred.label.clone(),
kind: cred.kind,
slot_index: usize::from(slot_index),
filename,
plaintext_size: header.plaintext_size,
},
header,
}));
}
}
Ok(Some(ScanHit { path: path.to_path_buf(), file_size, header, unlock: UnlockOutcome::Locked }))
}
fn read_filename(prefix: &[u8], header: &FixedHeader, fek: &crate::crypto::Fek) -> Option<String> {
let tlv_end = (header.header_len as usize).checked_sub(HEADER_MAC_LEN)?;
let blob = prefix.get(TLV_AREA_OFFSET..tlv_end)?;
let set = TlvSet::parse(blob).ok()?;
let plain = set.decrypt_value(types::FILENAME, fek, header.cipher_id).ok()?;
crate::tlv::unpad_filename(&plain).ok()
}
pub fn scan_dir(root: &Path, session: &SessionKeys, opts: &ScanOptions) -> Result<ScanResult> {
if !root.is_dir() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("not a directory: {}", root.display()),
)));
}
let mut out = ScanResult::default();
let mut stack = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if out.stats.files_examined >= opts.max_files {
break;
}
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => {
out.stats.io_errors = out.stats.io_errors.saturating_add(1);
continue;
}
};
for entry in rd.flatten() {
if out.stats.files_examined >= opts.max_files {
break;
}
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
out.stats.io_errors = out.stats.io_errors.saturating_add(1);
continue;
};
if meta.is_symlink() && !opts.follow_symlinks {
continue;
}
let is_dir = if meta.is_symlink() {
path.is_dir() } else {
meta.is_dir()
};
if is_dir {
if opts.recursive && depth < opts.max_depth {
stack.push((path, depth.saturating_add(1)));
}
continue;
}
if let Some(want) = opts.extension_filter.as_deref() {
let matches = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(want));
if !matches {
continue;
}
}
if meta.len() < MIN_FILE_SIZE as u64 {
continue;
}
out.stats.files_examined = out.stats.files_examined.saturating_add(1);
match probe_file(&path, session) {
Ok(Some(hit)) => {
out.stats.omy_found = out.stats.omy_found.saturating_add(1);
if hit.unlock.is_unlocked() {
out.stats.unlocked = out.stats.unlocked.saturating_add(1);
}
out.hits.push(hit);
}
Ok(None) => {} Err(Error::Io { .. }) => {
out.stats.io_errors = out.stats.io_errors.saturating_add(1);
}
Err(_) => {
out.stats.malformed = out.stats.malformed.saturating_add(1);
}
}
}
}
Ok(out)
}
pub fn scan_dirs(
roots: &[PathBuf],
session: &SessionKeys,
opts: &ScanOptions,
) -> Result<ScanResult> {
let mut merged = ScanResult::default();
let mut last_err = None;
let mut any_ok = false;
for root in roots {
match scan_dir(root, session, opts) {
Ok(r) => {
any_ok = true;
merged.hits.extend(r.hits);
let s = &mut merged.stats;
s.files_examined = s.files_examined.saturating_add(r.stats.files_examined);
s.omy_found = s.omy_found.saturating_add(r.stats.omy_found);
s.unlocked = s.unlocked.saturating_add(r.stats.unlocked);
s.io_errors = s.io_errors.saturating_add(r.stats.io_errors);
s.malformed = s.malformed.saturating_add(r.stats.malformed);
}
Err(e) => last_err = Some(e),
}
}
if any_ok {
Ok(merged)
} else {
Err(last_err.unwrap_or_else(|| {
Error::Io(std::io::Error::other("no directories scanned"))
}))
}
}