1#![forbid(unsafe_code)]
2
3use std::collections::HashMap;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use forensic_vfs::{
25 Allocation, DynFs, FileId, Layer, Locator, MacbTimes, NodeKind, StreamId, TimeStamp,
26 TimeZonePolicy, VfsError,
27};
28use forensic_vfs_engine::Vfs;
29
30use crate::{
31 not_supported, ForensicFs, FsAllocation, FsBlockRange, FsDeletedInode, FsDeletedNode,
32 FsDirEntry, FsError, FsFileType, FsMetadata, FsRecoveryResult, FsResult, FsTimelineEvent,
33 FsTimestamp, FsTransaction,
34};
35
36const ENUM_CAP: usize = 100_000;
39
40pub struct EngineFs {
46 fs: DynFs,
47 fwd: HashMap<FileId, u64>,
49 rev: HashMap<u64, FileId>,
50 next: u64,
51 root_u64: u64,
52 _tmp: Option<tempfile::TempPath>,
54}
55
56impl EngineFs {
57 fn new(fs: DynFs, tmp: Option<tempfile::TempPath>) -> Self {
60 let root = fs.root();
61 let mut this = Self {
62 fs,
63 fwd: HashMap::new(),
64 rev: HashMap::new(),
65 next: 10,
68 root_u64: 0,
69 _tmp: tmp,
70 };
71 this.root_u64 = this.assign(root);
72 this
73 }
74
75 fn assign(&mut self, id: FileId) -> u64 {
77 if let Some(&ino) = self.fwd.get(&id) {
78 return ino;
79 }
80 let ino = self.next;
81 self.next += 1;
82 self.fwd.insert(id, ino);
83 self.rev.insert(ino, id);
84 ino
85 }
86
87 fn file_id(&self, ino: u64) -> FsResult<FileId> {
89 self.rev
90 .get(&ino)
91 .copied()
92 .ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
93 }
94
95 #[must_use]
98 pub fn fs_kind_str(&self) -> &'static str {
99 self.fs.kind().as_str()
100 }
101}
102
103fn vfs_err(e: VfsError) -> FsError {
105 FsError::Other(e.to_string())
106}
107
108fn node_kind(k: NodeKind) -> FsFileType {
110 match k {
111 NodeKind::File => FsFileType::RegularFile,
112 NodeKind::Dir => FsFileType::Directory,
113 NodeKind::Symlink => FsFileType::Symlink,
114 NodeKind::Device => FsFileType::CharDevice,
115 _ => FsFileType::Unknown,
118 }
119}
120
121fn ts(t: Option<TimeStamp>) -> FsTimestamp {
124 match t {
125 Some(t) => FsTimestamp {
126 seconds: (t.unix_nanos.div_euclid(1_000_000_000)) as i64,
127 nanoseconds: (t.unix_nanos.rem_euclid(1_000_000_000)) as u32,
128 },
129 None => FsTimestamp::default(),
130 }
131}
132
133fn to_metadata(ino: u64, meta: &forensic_vfs::FsMeta, times: &MacbTimes) -> FsMetadata {
135 let file_type = node_kind(meta.kind);
136 let mode = meta.mode.map_or_else(
140 || match file_type {
141 FsFileType::Directory => 0o040_755,
142 FsFileType::Symlink => 0o120_777,
143 _ => 0o100_644,
144 },
145 |m| (m & 0xFFFF) as u16,
146 );
147 FsMetadata {
148 ino,
149 file_type,
150 mode,
151 uid: meta.uid.unwrap_or(0),
152 gid: meta.gid.unwrap_or(0),
153 size: meta.size,
154 links_count: meta.nlink.min(u32::from(u16::MAX)) as u16,
155 atime: ts(times.accessed),
156 mtime: ts(times.modified),
157 ctime: ts(times.changed),
158 crtime: ts(times.born),
159 allocated: matches!(meta.allocated, Allocation::Allocated),
160 }
161}
162
163impl ForensicFs for EngineFs {
164 fn root_ino(&self) -> u64 {
165 self.root_u64
166 }
167
168 fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
169 let id = self.file_id(ino)?;
170 let stream = self.fs.read_dir(id).map_err(vfs_err)?;
171 let mut out = Vec::new();
172 for entry in stream {
173 let entry = entry.map_err(vfs_err)?;
174 let child = self.assign(entry.id);
175 out.push(FsDirEntry {
176 inode: child,
177 name: entry.name,
178 file_type: node_kind(entry.kind),
179 });
180 }
181 Ok(out)
182 }
183
184 fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
185 let parent = self.file_id(parent_ino)?;
186 match self.fs.lookup(parent, name).map_err(vfs_err)? {
187 Some(id) => Ok(Some(self.assign(id))),
188 None => Ok(None),
189 }
190 }
191
192 fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
193 let id = self.file_id(ino)?;
194 let meta = self.fs.meta(id).map_err(vfs_err)?;
195 Ok(to_metadata(ino, &meta, &meta.times))
196 }
197
198 fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
199 let id = self.file_id(ino)?;
200 let size = self.fs.meta(id).map_err(vfs_err)?.size;
201 self.read_file_range(ino, 0, size)
202 }
203
204 fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
205 let id = self.file_id(ino)?;
206 let mut buf = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
207 let mut filled = 0usize;
208 while filled < buf.len() {
209 let n = self
210 .fs
211 .read_at(
212 id,
213 StreamId::Default,
214 offset + filled as u64,
215 &mut buf[filled..],
216 )
217 .map_err(vfs_err)?;
218 if n == 0 {
219 break;
220 }
221 filled += n;
222 }
223 buf.truncate(filled);
224 Ok(buf)
225 }
226
227 fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
228 let id = self.file_id(ino)?;
229 self.fs.read_link(id, 4096).map_err(vfs_err)
230 }
231
232 fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
233 let stream = self.fs.deleted().map_err(vfs_err)?;
234 let mut out = Vec::new();
235 for meta in stream.take(ENUM_CAP) {
236 let meta = meta.map_err(vfs_err)?;
237 out.push(FsDeletedInode {
238 ino: meta.ino,
239 file_type: node_kind(meta.kind),
240 size: meta.size,
241 dtime: 0,
242 recoverability: 0.0,
243 });
244 }
245 Ok(out)
246 }
247
248 fn deleted_nodes(&mut self) -> FsResult<Vec<FsDeletedNode>> {
249 let stream = self.fs.deleted_nodes().map_err(vfs_err)?;
255 let mut out = Vec::new();
256 for node in stream.take(ENUM_CAP) {
257 let node = node.map_err(vfs_err)?;
258 let ino = self.assign(node.id);
259 let parent_ino = node.parent.map(|p| self.assign(p));
260 let meta = &node.meta;
261 let allocation = match meta.allocated {
262 Allocation::Orphan => FsAllocation::Orphan,
263 _ => FsAllocation::Deleted,
266 };
267 out.push(FsDeletedNode {
268 ino,
269 name: node.name.clone(),
270 parent_ino,
271 size: meta.size,
272 file_type: node_kind(meta.kind),
273 allocation,
274 record_id: meta.ino,
275 atime: ts(meta.times.accessed),
276 mtime: ts(meta.times.modified),
277 ctime: ts(meta.times.changed),
278 crtime: ts(meta.times.born),
279 });
280 }
281 Ok(out)
282 }
283
284 fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
285 let _ = ino;
289 Err(not_supported(
290 "recover_file (the forensic-vfs FileSystem trait has no deleted-content read path)",
291 ))
292 }
293
294 fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
295 Err(not_supported(
297 "timeline (the forensic-vfs FileSystem trait has no event-timeline surface)",
298 ))
299 }
300
301 fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
302 let bs = self.block_size().max(1);
303 let stream = self.fs.unallocated().map_err(vfs_err)?;
304 let mut out = Vec::new();
305 for run in stream.take(ENUM_CAP) {
306 let run = run.map_err(vfs_err)?;
307 out.push(FsBlockRange {
308 start: run.run.image_offset,
309 length: (run.run.len / bs).max(1),
312 });
313 }
314 Ok(out)
315 }
316
317 fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
318 Err(not_supported(
323 "read_unallocated (the forensic-vfs FileSystem trait has no raw-image byte reader)",
324 ))
325 }
326
327 fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
328 Err(not_supported(
330 "journal_transactions (the forensic-vfs FileSystem trait has no journal surface)",
331 ))
332 }
333
334 fn fs_info(&self) -> FsResult<serde_json::Value> {
335 let sizes = self.fs.sector_sizes();
336 let zone = match self.fs.timestamp_zone() {
337 TimeZonePolicy::Utc => "utc".to_string(),
338 TimeZonePolicy::LocalUnknown => "local-unknown".to_string(),
339 TimeZonePolicy::Local { minutes_east } => format!("local+{minutes_east}m"),
340 _ => "unknown".to_string(),
341 };
342 Ok(serde_json::json!({
343 "filesystem": self.fs.kind().as_str(),
344 "logical_sector_size": sizes.logical,
345 "physical_sector_size": sizes.physical,
346 "cluster_or_block_size": sizes.cluster_or_block,
347 "timestamp_zone": zone,
348 }))
349 }
350
351 fn block_size(&self) -> u64 {
352 let bs = self.fs.sector_sizes().cluster_or_block;
353 if bs == 0 {
354 4096
355 } else {
356 u64::from(bs)
357 }
358 }
359}
360
361pub fn open_image(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
372 if let Some(tmp) = try_peel_to_tmp(path)? {
373 let fs = mount_engine(tmp.path())?;
374 return Ok(Box::new(EngineFs::new(fs, Some(tmp.into_temp_path()))));
375 }
376 let fs = mount_engine(path)?;
377 Ok(Box::new(EngineFs::new(fs, None)))
378}
379
380pub fn open_image_all(path: &Path) -> io::Result<Box<dyn ForensicFs + Send>> {
407 let (image, tmp): (PathBuf, Option<tempfile::TempPath>) = match try_peel_to_tmp(path)? {
410 Some(nt) => (nt.path().to_path_buf(), Some(nt.into_temp_path())),
411 None => (path.to_path_buf(), None),
412 };
413
414 let evidences = Vfs::new()
415 .open_all(&image)
416 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
417 let pairs: Vec<(Locator, DynFs)> = evidences
420 .into_iter()
421 .filter_map(|e| e.fs.map(|fs| (e.root, fs)))
422 .collect();
423
424 if pairs.is_empty() {
425 return Err(io::Error::new(
426 io::ErrorKind::InvalidData,
427 format!(
428 "no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
429 image.display()
430 ),
431 ));
432 }
433
434 let mut parts = Vec::with_capacity(pairs.len());
438 let mut labels = Vec::with_capacity(pairs.len());
439 let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
440 for (spec, fs) in pairs {
441 let label = volume_label(&spec, &fs);
443 let name = volume_dir_name(volume_index(&spec), label, &used);
444 used.insert(name.clone());
445 labels.push(name.into_bytes());
446 parts.push(EngineFs::new(fs, None));
447 }
448 Ok(Box::new(MultiPartitionFs::new(parts, labels, tmp)))
449}
450
451fn volume_label(_spec: &Locator, fs: &DynFs) -> Option<String> {
459 fs.volume_label()
460}
461
462fn volume_index(spec: &Locator) -> Option<usize> {
466 spec.layers().into_iter().find_map(|l| match l {
467 Layer::Volume { index, .. } => Some(*index),
468 _ => None,
469 })
470}
471
472fn volume_dir_name(
484 volume_index: Option<usize>,
485 label: Option<String>,
486 used: &std::collections::HashSet<String>,
487) -> String {
488 if let Some(raw) = label {
489 let sanitized = sanitize_volume_label(&raw);
490 if !sanitized.is_empty() && !used.contains(&sanitized) {
491 return sanitized;
492 }
493 }
494 match volume_index {
495 Some(idx) => format!("_partition{}", idx + 1),
496 None => "root".to_string(),
497 }
498}
499
500fn sanitize_volume_label(label: &str) -> String {
508 const HEX: &[u8; 16] = b"0123456789ABCDEF";
509 let mut out = String::with_capacity(label.len());
510 for ch in label.chars() {
511 if should_percent_encode(ch) {
512 let mut buf = [0u8; 4];
513 for b in ch.encode_utf8(&mut buf).bytes() {
514 out.push('%');
515 out.push(HEX[(b >> 4) as usize] as char);
516 out.push(HEX[(b & 0x0F) as usize] as char);
517 }
518 } else {
519 out.push(ch);
520 }
521 }
522 out
523}
524
525fn should_percent_encode(ch: char) -> bool {
528 if ch == '%' || ch == '/' {
530 return true;
531 }
532 if ch.is_control() {
534 return true;
535 }
536 if matches!(ch,
538 '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
539 {
540 return true;
541 }
542 #[cfg(windows)]
544 if matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') {
545 return true;
546 }
547 false
548}
549
550fn try_peel_to_tmp(path: &Path) -> io::Result<Option<tempfile::NamedTempFile>> {
555 use std::io::{Read, Write};
556
557 let name = path.file_name().and_then(|n| n.to_str());
558 let mut head = [0u8; 16];
562 let read = {
563 let mut file = std::fs::File::open(path)?;
564 file.read(&mut head)?
565 };
566 if !archive_core::sniff(name, &head[..read]).is_compression_wrapper() {
567 return Ok(None);
568 }
569 let data = std::fs::read(path)?;
570 match archive_core::peel_archive(&data, name, &archive_core::Limits::default()) {
571 Ok(archive_core::Peel::Inner(inner)) => {
572 let mut tmp = tempfile::Builder::new().suffix(".img").tempfile()?;
573 tmp.write_all(&inner)?;
574 tmp.flush()?;
575 Ok(Some(tmp))
576 }
577 Ok(archive_core::Peel::NotPacked) => Ok(None),
578 Err(e) => Err(io::Error::new(
579 io::ErrorKind::InvalidData,
580 format!("archive peel failed: {e}"),
581 )),
582 }
583}
584
585fn mount_engine(path: &Path) -> io::Result<DynFs> {
587 let evidence = Vfs::new()
588 .open(path)
589 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
590 evidence.fs.ok_or_else(|| {
591 io::Error::new(
592 io::ErrorKind::InvalidData,
593 format!(
594 "no filesystem detected in {} (unsupported container/volume/filesystem, or empty image)",
595 path.display()
596 ),
597 )
598 })
599}
600
601const MP_ROOT_INO: u64 = 1;
603
604pub struct MultiPartitionFs {
620 parts: Vec<EngineFs>,
624 labels: Vec<Vec<u8>>,
627 fwd: HashMap<(usize, u64), u64>,
629 rev: HashMap<u64, (usize, u64)>,
630 next: u64,
632 _tmp: Option<tempfile::TempPath>,
634}
635
636impl MultiPartitionFs {
637 fn new(parts: Vec<EngineFs>, labels: Vec<Vec<u8>>, tmp: Option<tempfile::TempPath>) -> Self {
640 debug_assert_eq!(parts.len(), labels.len());
641 Self {
642 parts,
643 labels,
644 fwd: HashMap::new(),
645 rev: HashMap::new(),
646 next: MP_ROOT_INO + 1,
647 _tmp: tmp,
648 }
649 }
650
651 fn assign(&mut self, part: usize, inner: u64) -> u64 {
654 if let Some(&global) = self.fwd.get(&(part, inner)) {
655 return global;
656 }
657 let global = self.next;
658 self.next += 1;
659 self.fwd.insert((part, inner), global);
660 self.rev.insert(global, (part, inner));
661 global
662 }
663
664 fn resolve(&self, ino: u64) -> FsResult<(usize, u64)> {
666 self.rev
667 .get(&ino)
668 .copied()
669 .ok_or_else(|| FsError::NotFound(format!("unknown inode {ino}")))
670 }
671
672 fn dispatch_file(&self, ino: u64) -> FsResult<(usize, u64)> {
675 if ino == MP_ROOT_INO {
676 return Err(FsError::Other(
677 "the multi-partition root is a directory, not a file".to_string(),
678 ));
679 }
680 self.resolve(ino)
681 }
682}
683
684fn synthetic_root_metadata() -> FsMetadata {
686 FsMetadata {
687 ino: MP_ROOT_INO,
688 file_type: FsFileType::Directory,
689 mode: 0o040_555,
690 uid: 0,
691 gid: 0,
692 size: 0,
693 links_count: 2,
694 atime: FsTimestamp::default(),
695 mtime: FsTimestamp::default(),
696 ctime: FsTimestamp::default(),
697 crtime: FsTimestamp::default(),
698 allocated: true,
699 }
700}
701
702impl ForensicFs for MultiPartitionFs {
703 fn root_ino(&self) -> u64 {
704 MP_ROOT_INO
705 }
706
707 fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
708 if ino == MP_ROOT_INO {
709 let mut out = Vec::with_capacity(self.parts.len());
710 for idx in 0..self.parts.len() {
711 let inner_root = self.parts[idx].root_ino();
712 let inode = self.assign(idx, inner_root);
713 out.push(FsDirEntry {
714 inode,
715 name: self.labels[idx].clone(),
716 file_type: FsFileType::Directory,
717 });
718 }
719 return Ok(out);
720 }
721 let (part, inner) = self.resolve(ino)?;
722 let entries = self.parts[part].read_dir(inner)?;
723 let mut out = Vec::with_capacity(entries.len());
724 for e in entries {
725 let inode = self.assign(part, e.inode);
726 out.push(FsDirEntry {
727 inode,
728 name: e.name,
729 file_type: e.file_type,
730 });
731 }
732 Ok(out)
733 }
734
735 fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
736 if parent_ino == MP_ROOT_INO {
737 if name == b"." || name == b".." {
738 return Ok(Some(MP_ROOT_INO));
739 }
740 for idx in 0..self.parts.len() {
741 if self.labels[idx].as_slice() == name {
742 let inner_root = self.parts[idx].root_ino();
743 return Ok(Some(self.assign(idx, inner_root)));
744 }
745 }
746 return Ok(None);
747 }
748 let (part, inner) = self.resolve(parent_ino)?;
749 match self.parts[part].lookup(inner, name)? {
750 Some(child) => Ok(Some(self.assign(part, child))),
751 None => Ok(None),
752 }
753 }
754
755 fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
756 if ino == MP_ROOT_INO {
757 return Ok(synthetic_root_metadata());
758 }
759 let (part, inner) = self.resolve(ino)?;
760 let mut meta = self.parts[part].metadata(inner)?;
761 meta.ino = ino;
763 Ok(meta)
764 }
765
766 fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
767 let (part, inner) = self.dispatch_file(ino)?;
768 self.parts[part].read_file(inner)
769 }
770
771 fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
772 let (part, inner) = self.dispatch_file(ino)?;
773 self.parts[part].read_file_range(inner, offset, len)
774 }
775
776 fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
777 let (part, inner) = self.dispatch_file(ino)?;
778 self.parts[part].read_link(inner)
779 }
780
781 fn block_size(&self) -> u64 {
782 self.parts.first().map_or(4096, ForensicFs::block_size)
783 }
784}
785
786#[cfg(test)]
787mod layout_tests {
788 use super::{sanitize_volume_label, volume_dir_name};
793 use std::collections::HashSet;
794
795 #[test]
796 fn label_kept_verbatim_including_spaces_and_unicode() {
797 let used = HashSet::new();
798 assert_eq!(
799 volume_dir_name(Some(0), Some("System Reserved".to_string()), &used),
800 "System Reserved",
801 "a label keeps its spaces/case verbatim (ADR-0010)"
802 );
803 assert_eq!(
804 sanitize_volume_label("Café"),
805 "Café",
806 "Unicode is kept verbatim"
807 );
808 }
809
810 #[test]
811 fn label_slash_is_percent_encoded() {
812 let used = HashSet::new();
813 assert_eq!(
814 volume_dir_name(Some(1), Some("a/b".to_string()), &used),
815 "a%2Fb",
816 "`/` is reversibly percent-encoded so it cannot split the path"
817 );
818 }
819
820 #[test]
821 fn no_label_with_volume_layer_is_partition_index_plus_one() {
822 let used = HashSet::new();
823 assert_eq!(volume_dir_name(Some(0), None, &used), "_partition1");
824 assert_eq!(volume_dir_name(Some(2), None, &used), "_partition3");
825 }
826
827 #[test]
828 fn no_label_no_volume_layer_is_root() {
829 let used = HashSet::new();
830 assert_eq!(
831 volume_dir_name(None, None, &used),
832 "root",
833 "a bare unpartitioned filesystem renders as a single `root` volume"
834 );
835 }
836
837 #[test]
838 fn empty_or_colliding_label_falls_back_to_partition() {
839 let mut used = HashSet::new();
840 assert_eq!(
841 volume_dir_name(Some(0), Some(String::new()), &used),
842 "_partition1",
843 "an empty sanitized label falls back to the partition index"
844 );
845 used.insert("dup".to_string());
846 assert_eq!(
847 volume_dir_name(Some(1), Some("dup".to_string()), &used),
848 "_partition2",
849 "a colliding label falls back to the partition index"
850 );
851 }
852
853 #[test]
854 fn sanitize_encodes_control_bidi_and_percent_reversibly() {
855 assert_eq!(
856 sanitize_volume_label("x\ty"),
857 "x%09y",
858 "TAB control encoded"
859 );
860 assert_eq!(
861 sanitize_volume_label("a\u{202E}b"),
862 "a%E2%80%AEb",
863 "the RIGHT-TO-LEFT OVERRIDE bidi char is encoded to its UTF-8 bytes"
864 );
865 assert_eq!(
866 sanitize_volume_label("50%"),
867 "50%25",
868 "`%` is escaped for reversibility"
869 );
870 assert_eq!(sanitize_volume_label("NUL\0x"), "NUL%00x", "NUL encoded");
871 }
872}