Skip to main content

vsc/
lib.rs

1//! # vsc-core — Windows Volume Shadow Copy (VSS) reader
2//!
3//! A panic-free decoder for the on-disk structures of Windows Volume Shadow Copy
4//! (VSS), the `[P^H]` disk-history substrate of the forensic fleet. Given a
5//! positioned `Read + Seek` over an NTFS **volume** (offset 0 = the NTFS boot
6//! sector), [`VssVolume::open`] reads the VSS volume header at byte offset
7//! `0x1E00`, walks the catalog of shadow-copy stores, and exposes each store's
8//! [`StoreInfo`] on demand.
9//!
10//! The reader stays pure: it decodes bytes into typed records and makes no
11//! forensic judgments (those live in the sibling `vsc-forensic` analyzer). It
12//! never loads the whole volume into memory — the real volumes are hundreds of
13//! gigabytes — and every multi-byte read is bounds-checked, so malformed input
14//! yields safe defaults or a typed [`VssError`], never a panic.
15//!
16//! It enumerates stores and decodes store information plus the typed diff-area
17//! records ([`BlockDescriptor`], [`StoreBlockRange`]), and reconstructs a
18//! snapshot's copy-on-write view of the volume via [`VssVolume::snapshot`] →
19//! [`Snapshot::read_block`] / [`Snapshot::read_at`].
20
21#![forbid(unsafe_code)]
22#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
23
24use std::collections::HashSet;
25use std::io::{Read, Seek, SeekFrom};
26
27pub mod block;
28mod bytes;
29pub mod catalog;
30pub mod error;
31pub mod guid;
32pub mod reconstruct;
33pub mod store;
34#[cfg(feature = "vfs")]
35pub mod vfs;
36
37#[cfg(test)]
38mod tests;
39
40pub use block::{BlockDescriptor, BlockDescriptorFlags, StoreBlockRange};
41pub use catalog::{StoreDescriptor, VolumeHeader};
42pub use error::VssError;
43pub use guid::{format_guid, VSS_IDENTIFIER};
44pub use reconstruct::Snapshot;
45pub use store::{AttributeFlags, StoreBlockHeader, StoreInfo};
46
47use catalog::{
48    catalog_next_block_offset, is_catalog_block, parse_catalog_entry, CatalogEntry, BLOCK_SIZE,
49    CATALOG_BLOCK_HEADER_LEN, CATALOG_ENTRY_LEN, MAX_CATALOG_BLOCKS, VSS_VOLUME_HEADER_OFFSET,
50};
51use store::{MAX_STORE_INFO_LEN, STORE_BLOCK_HEADER_LEN};
52
53/// A read-only view over the Volume Shadow Copy metadata of an NTFS volume.
54///
55/// Construct with [`VssVolume::open`] over a `Read + Seek` positioned at the
56/// start of the NTFS volume. The catalog of stores is read eagerly (it is
57/// small); each store's [`StoreInfo`] is read lazily via
58/// [`VssVolume::store_info`].
59#[derive(Debug)]
60pub struct VssVolume<R> {
61    reader: R,
62    volume_size: u64,
63    has_vss_header: bool,
64    catalog_offset: u64,
65    stores: Vec<StoreDescriptor>,
66}
67
68impl<R: Read + Seek> VssVolume<R> {
69    /// Open a VSS view over a positioned NTFS volume reader.
70    ///
71    /// Reads the volume header at `0x1E00`; if it carries the VSS identifier and
72    /// names a catalog, walks the catalog and enumerates the stores. A volume
73    /// with no VSS header (the header region is zeroed) opens successfully with
74    /// [`VssVolume::has_vss_header`] `== false` and zero stores.
75    ///
76    /// # Errors
77    /// Returns [`VssError::Io`] on an underlying read/seek failure.
78    pub fn open(mut reader: R) -> Result<Self, VssError> {
79        let volume_size = reader.seek(SeekFrom::End(0))?;
80
81        let mut has_vss_header = false;
82        let mut catalog_offset = 0u64;
83        if volume_size >= VSS_VOLUME_HEADER_OFFSET + CATALOG_BLOCK_HEADER_LEN as u64 {
84            reader.seek(SeekFrom::Start(VSS_VOLUME_HEADER_OFFSET))?;
85            let mut hdr = [0u8; CATALOG_BLOCK_HEADER_LEN];
86            reader.read_exact(&mut hdr)?;
87            let vh = VolumeHeader::parse(&hdr);
88            has_vss_header = vh.has_vss_identifier;
89            catalog_offset = vh.catalog_offset;
90        }
91
92        let stores = if has_vss_header && catalog_offset != 0 {
93            walk_catalog(&mut reader, catalog_offset, volume_size)?
94        } else {
95            Vec::new()
96        };
97
98        Ok(VssVolume {
99            reader,
100            volume_size,
101            has_vss_header,
102            catalog_offset,
103            stores,
104        })
105    }
106
107    /// Whether the volume carries a VSS volume header at `0x1E00`.
108    #[must_use]
109    pub fn has_vss_header(&self) -> bool {
110        self.has_vss_header
111    }
112
113    /// The enumerated shadow-copy store descriptors.
114    #[must_use]
115    pub fn stores(&self) -> &[StoreDescriptor] {
116        &self.stores
117    }
118
119    /// The number of enumerated shadow-copy stores.
120    #[must_use]
121    pub fn store_count(&self) -> usize {
122        self.stores.len()
123    }
124
125    /// The catalog offset from the volume header (0 when there is no catalog).
126    #[must_use]
127    pub fn catalog_offset(&self) -> u64 {
128        self.catalog_offset
129    }
130
131    /// The total size of the underlying volume, in bytes.
132    #[must_use]
133    pub fn volume_size(&self) -> u64 {
134        self.volume_size
135    }
136
137    /// Read and decode the store information for store `index`.
138    ///
139    /// # Errors
140    /// - [`VssError::StoreIndexOutOfRange`] if `index` is past the last store.
141    /// - [`VssError::StoreInfoUnavailable`] if the store has no type-0x03
142    ///   catalog pointer.
143    /// - [`VssError::StoreOffsetOutOfBounds`] if the store-header offset runs
144    ///   past the end of the volume.
145    /// - [`VssError::Io`] on an underlying read/seek failure.
146    pub fn store_info(&mut self, index: usize) -> Result<StoreInfo, VssError> {
147        let count = self.stores.len();
148        let header_off = self
149            .stores
150            .get(index)
151            .ok_or(VssError::StoreIndexOutOfRange { index, count })?
152            .store_header_offset
153            .ok_or(VssError::StoreInfoUnavailable { index })?;
154
155        // Range-check the store-header offset before seeking: it comes straight
156        // from the catalog and must not be trusted.
157        let header_end = header_off
158            .checked_add(STORE_BLOCK_HEADER_LEN as u64)
159            .filter(|end| *end <= self.volume_size)
160            .ok_or(VssError::StoreOffsetOutOfBounds {
161                index,
162                offset: header_off,
163                volume_size: self.volume_size,
164            })?;
165
166        self.reader.seek(SeekFrom::Start(header_off))?;
167        let mut hbuf = [0u8; STORE_BLOCK_HEADER_LEN];
168        self.reader.read_exact(&mut hbuf)?;
169        let header = StoreBlockHeader::parse(&hbuf);
170
171        // Cap the store-information read against a lying size field and the tail
172        // of the volume.
173        let remaining = self.volume_size.saturating_sub(header_end) as usize;
174        let want = usize::try_from(header.store_information_size).unwrap_or(usize::MAX);
175        let info_len = want.min(MAX_STORE_INFO_LEN).min(remaining);
176        let mut ibuf = vec![0u8; info_len];
177        self.reader.read_exact(&mut ibuf)?;
178        Ok(StoreInfo::parse(&ibuf))
179    }
180}
181
182/// Walk the catalog block chain starting at `first`, collecting every snapshot
183/// descriptor and attaching each store-header offset from the paired type-0x03
184/// entry.
185///
186/// The walk is bounded three ways against a corrupt or adversarial catalog: a
187/// visited-set breaks any offset cycle, [`MAX_CATALOG_BLOCKS`] caps the chain
188/// length, and every block is range-checked against the volume before it is
189/// read.
190fn walk_catalog<R: Read + Seek>(
191    reader: &mut R,
192    first: u64,
193    volume_size: u64,
194) -> Result<Vec<StoreDescriptor>, VssError> {
195    let mut stores: Vec<StoreDescriptor> = Vec::new();
196    let mut visited: HashSet<u64> = HashSet::new();
197    let mut next = first;
198    let mut blocks = 0usize;
199
200    while next != 0 && blocks < MAX_CATALOG_BLOCKS {
201        if !visited.insert(next) {
202            break; // cycle
203        }
204        match next.checked_add(BLOCK_SIZE as u64) {
205            Some(end) if end <= volume_size => {}
206            _ => break, // out of range / overflow
207        }
208
209        reader.seek(SeekFrom::Start(next))?;
210        let mut block = vec![0u8; BLOCK_SIZE];
211        reader.read_exact(&mut block)?;
212        if !is_catalog_block(&block) {
213            break;
214        }
215
216        let mut off = CATALOG_BLOCK_HEADER_LEN;
217        while off + CATALOG_ENTRY_LEN <= BLOCK_SIZE {
218            match parse_catalog_entry(&block[off..off + CATALOG_ENTRY_LEN]) {
219                CatalogEntry::Snapshot(descriptor) => stores.push(descriptor),
220                CatalogEntry::StorePointer {
221                    store_id,
222                    store_header_offset,
223                    store_bitmap_offset,
224                } => {
225                    if let Some(descriptor) = stores
226                        .iter_mut()
227                        .rev()
228                        .find(|d| d.store_id == store_id && d.store_header_offset.is_none())
229                    {
230                        descriptor.store_header_offset = Some(store_header_offset);
231                        descriptor.store_bitmap_offset = Some(store_bitmap_offset);
232                    }
233                }
234                CatalogEntry::Empty | CatalogEntry::Other => {}
235            }
236            off += CATALOG_ENTRY_LEN;
237        }
238
239        blocks += 1;
240        next = catalog_next_block_offset(&block);
241    }
242
243    Ok(stores)
244}