Skip to main content

weavatrix_scan/
snapshot.rs

1use crate::file_version;
2use crate::hash::FingerprintHasher;
3use crate::path::normalized_relative_path;
4use crate::report::{ScanReport, ScannedFile};
5use std::fmt;
6use std::fs::{self, File};
7use std::io::{self, Read};
8use std::path::Path;
9
10/// Evidence used to bind returned bytes to a scan snapshot.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SnapshotEvidence {
13    FileVersion,
14    Sha256,
15}
16
17/// File bytes verified against the selected scan entry.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SnapshotContent {
20    pub bytes: Vec<u8>,
21    pub evidence: SnapshotEvidence,
22}
23
24/// Reads only files recorded in one validated [`ScanReport`].
25#[derive(Debug, Clone, Copy)]
26pub struct SnapshotContentProvider<'a> {
27    root: &'a Path,
28    files: &'a [ScannedFile],
29}
30
31/// A path-safe failure from snapshot content access.
32#[derive(Debug)]
33pub enum SnapshotReadError {
34    InvalidReport {
35        relative: Option<String>,
36        reason: &'static str,
37    },
38    UnknownFile(String),
39    Io {
40        relative: String,
41        source: io::Error,
42    },
43    LimitExceeded {
44        relative: String,
45        bytes: u64,
46        max_bytes: u64,
47    },
48    Stale(String),
49}
50
51impl ScanReport {
52    /// Creates a provider after validating report ordering and path scope.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`SnapshotReadError::InvalidReport`] for a malformed,
57    /// path-escaping, duplicate, or unverifiable report entry.
58    pub fn content_provider(&self) -> Result<SnapshotContentProvider<'_>, SnapshotReadError> {
59        SnapshotContentProvider::new(self)
60    }
61}
62
63impl<'a> SnapshotContentProvider<'a> {
64    /// Validates a scan report before any content is opened.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`SnapshotReadError::InvalidReport`] when report entries are
69    /// not strictly sorted, escape the root, or lack snapshot evidence.
70    pub fn new(report: &'a ScanReport) -> Result<Self, SnapshotReadError> {
71        validate_report(report)?;
72        Ok(Self {
73            root: &report.root,
74            files: &report.files,
75        })
76    }
77
78    /// Reads a selected file and verifies it before and after the read.
79    ///
80    /// # Errors
81    ///
82    /// Returns `UnknownFile` for paths outside this snapshot, `Stale` when the
83    /// file changed or became a link, and `Io` for independent read failures.
84    pub fn read(&self, relative: &str) -> Result<SnapshotContent, SnapshotReadError> {
85        self.read_bounded(relative, u64::MAX)
86    }
87
88    /// Reads a selected file only when its recorded size is within `max_bytes`.
89    ///
90    /// # Errors
91    ///
92    /// Returns the same failures as [`Self::read`], plus `LimitExceeded`.
93    pub fn read_bounded(
94        &self,
95        relative: &str,
96        max_bytes: u64,
97    ) -> Result<SnapshotContent, SnapshotReadError> {
98        let index = self
99            .files
100            .binary_search_by(|file| file.relative.as_str().cmp(relative))
101            .map_err(|_| SnapshotReadError::UnknownFile(relative.to_owned()))?;
102        let snapshot = &self.files[index];
103        if snapshot.bytes > max_bytes {
104            return Err(SnapshotReadError::LimitExceeded {
105                relative: relative.to_owned(),
106                bytes: snapshot.bytes,
107                max_bytes,
108            });
109        }
110        self.read_file(snapshot)
111    }
112
113    fn read_file(&self, snapshot: &ScannedFile) -> Result<SnapshotContent, SnapshotReadError> {
114        let relative = snapshot.relative.as_str();
115        let expected_bytes =
116            usize::try_from(snapshot.bytes).map_err(|_| SnapshotReadError::LimitExceeded {
117                relative: relative.to_owned(),
118                bytes: snapshot.bytes,
119                max_bytes: u64::try_from(usize::MAX).unwrap_or(u64::MAX),
120            })?;
121        let link_metadata =
122            fs::symlink_metadata(&snapshot.absolute).map_err(|error| map_io(relative, error))?;
123        if link_metadata.file_type().is_symlink() {
124            return Err(SnapshotReadError::Stale(relative.to_owned()));
125        }
126        let canonical = snapshot
127            .absolute
128            .canonicalize()
129            .map_err(|error| map_io(relative, error))?;
130        if !canonical.starts_with(self.root) {
131            return Err(SnapshotReadError::Stale(relative.to_owned()));
132        }
133        let mut file = File::open(&snapshot.absolute).map_err(|error| map_io(relative, error))?;
134        let before_metadata = file.metadata().map_err(|error| map_io(relative, error))?;
135        if !before_metadata.is_file() || before_metadata.len() != snapshot.bytes {
136            return Err(SnapshotReadError::Stale(relative.to_owned()));
137        }
138        let before = file_version::from_file(&file, &before_metadata)
139            .map_err(|error| map_io(relative, error))?;
140        let version_matches = file_version::reusable(&snapshot.version, &before);
141        if !version_matches && snapshot.content_hash.is_none() {
142            return Err(SnapshotReadError::Stale(relative.to_owned()));
143        }
144
145        let read_limit = snapshot.bytes.saturating_add(1);
146        let mut bytes = Vec::new();
147        (&mut file)
148            .take(read_limit)
149            .read_to_end(&mut bytes)
150            .map_err(|error| map_io(relative, error))?;
151        if bytes.len() != expected_bytes {
152            return Err(SnapshotReadError::Stale(relative.to_owned()));
153        }
154
155        let after_metadata = file.metadata().map_err(|error| map_io(relative, error))?;
156        let after = file_version::from_file(&file, &after_metadata)
157            .map_err(|error| map_io(relative, error))?;
158        if !file_version::reusable(&before, &after) {
159            return Err(SnapshotReadError::Stale(relative.to_owned()));
160        }
161
162        let evidence = if let Some(expected_hash) = &snapshot.content_hash {
163            let mut hash = FingerprintHasher::new();
164            hash.write(&bytes);
165            if hash.finish() != *expected_hash {
166                return Err(SnapshotReadError::Stale(relative.to_owned()));
167            }
168            SnapshotEvidence::Sha256
169        } else {
170            SnapshotEvidence::FileVersion
171        };
172        Ok(SnapshotContent { bytes, evidence })
173    }
174}
175
176impl fmt::Display for SnapshotReadError {
177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            Self::InvalidReport { relative, reason } => {
180                write!(
181                    formatter,
182                    "invalid scan report entry {}: {reason}",
183                    relative.as_deref().unwrap_or("<root>")
184                )
185            }
186            Self::UnknownFile(relative) => {
187                write!(
188                    formatter,
189                    "file is not present in the scan snapshot: {relative}"
190                )
191            }
192            Self::Io { relative, source } => {
193                write!(
194                    formatter,
195                    "could not read scan snapshot file {relative}: {source}"
196                )
197            }
198            Self::LimitExceeded {
199                relative,
200                bytes,
201                max_bytes,
202            } => write!(
203                formatter,
204                "scan snapshot file exceeds content limit: {relative} ({bytes} > {max_bytes})"
205            ),
206            Self::Stale(relative) => write!(formatter, "scan snapshot is stale: {relative}"),
207        }
208    }
209}
210
211impl std::error::Error for SnapshotReadError {
212    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
213        match self {
214            Self::Io { source, .. } => Some(source),
215            Self::InvalidReport { .. }
216            | Self::UnknownFile(_)
217            | Self::LimitExceeded { .. }
218            | Self::Stale(_) => None,
219        }
220    }
221}
222
223fn validate_report(report: &ScanReport) -> Result<(), SnapshotReadError> {
224    if !report.root.is_absolute() {
225        return Err(invalid(None, "root is not absolute"));
226    }
227    let mut previous: Option<&str> = None;
228    for file in &report.files {
229        if previous.is_some_and(|value| value >= file.relative.as_str()) {
230            return Err(invalid(
231                Some(file.relative.clone()),
232                "files are not strictly sorted",
233            ));
234        }
235        previous = Some(&file.relative);
236        let relative = file
237            .absolute
238            .strip_prefix(&report.root)
239            .map_err(|_| invalid(Some(file.relative.clone()), "absolute path escapes root"))?;
240        if normalized_relative_path(relative) != file.relative {
241            return Err(invalid(
242                Some(file.relative.clone()),
243                "relative and absolute paths disagree",
244            ));
245        }
246        if file.content_hash.is_none() && file.version.modified_ns.is_none() {
247            return Err(invalid(
248                Some(file.relative.clone()),
249                "entry has no reusable snapshot evidence",
250            ));
251        }
252    }
253    Ok(())
254}
255
256fn invalid(relative: Option<String>, reason: &'static str) -> SnapshotReadError {
257    SnapshotReadError::InvalidReport { relative, reason }
258}
259
260fn map_io(relative: &str, source: io::Error) -> SnapshotReadError {
261    if source.kind() == io::ErrorKind::NotFound {
262        SnapshotReadError::Stale(relative.to_owned())
263    } else {
264        SnapshotReadError::Io {
265            relative: relative.to_owned(),
266            source,
267        }
268    }
269}