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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SnapshotEvidence {
13 FileVersion,
14 Sha256,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SnapshotContent {
20 pub bytes: Vec<u8>,
21 pub evidence: SnapshotEvidence,
22}
23
24#[derive(Debug, Clone, Copy)]
26pub struct SnapshotContentProvider<'a> {
27 root: &'a Path,
28 files: &'a [ScannedFile],
29}
30
31#[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 pub fn content_provider(&self) -> Result<SnapshotContentProvider<'_>, SnapshotReadError> {
59 SnapshotContentProvider::new(self)
60 }
61}
62
63impl<'a> SnapshotContentProvider<'a> {
64 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 pub fn read(&self, relative: &str) -> Result<SnapshotContent, SnapshotReadError> {
85 self.read_bounded(relative, u64::MAX)
86 }
87
88 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}