1use std::fs::File;
4use std::io::{BufReader, Read};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::IoError;
11
12pub const DATASET_MANIFEST_VERSION: u32 = 1;
14
15#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ReceiptRole {
19 Input,
21 Output,
23 Auxiliary,
25}
26
27#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29pub struct FileReceipt {
30 pub role: ReceiptRole,
32 pub path: PathBuf,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub size_bytes: Option<u64>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub sha256: Option<String>,
40}
41
42impl FileReceipt {
43 pub fn from_path(role: ReceiptRole, path: impl AsRef<Path>) -> Result<Self, IoError> {
45 let path = path.as_ref();
46 let file = File::open(path)?;
47 let mut reader = BufReader::new(file);
48 let mut hasher = Sha256::new();
49 let mut bytes = 0_u64;
50 let mut buffer = [0_u8; 64 * 1024];
51
52 loop {
53 let read = reader.read(&mut buffer)?;
54 if read == 0 {
55 break;
56 }
57 hasher.update(&buffer[..read]);
58 bytes = bytes.checked_add(read as u64).ok_or_else(|| {
59 IoError::Manifest(format!("file size overflow while hashing `{}`", path.display()))
60 })?;
61 }
62
63 let digest = hasher.finalize();
64 Ok(Self {
65 role,
66 path: path.to_path_buf(),
67 size_bytes: Some(bytes),
68 sha256: Some(hex_digest(&digest)),
69 })
70 }
71
72 #[must_use]
74 pub fn from_uri(role: ReceiptRole, uri: impl Into<PathBuf>) -> Self {
75 Self { role, path: uri.into(), size_bytes: None, sha256: None }
76 }
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct DatasetManifest {
82 pub version: u32,
84 pub entries: Vec<FileReceipt>,
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct ManifestValidation {
91 pub checked_local_files: u64,
93 pub uri_entries: u64,
95 pub total_bytes: u64,
97}
98
99impl Default for DatasetManifest {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl DatasetManifest {
106 #[must_use]
108 pub const fn new() -> Self {
109 Self { version: DATASET_MANIFEST_VERSION, entries: Vec::new() }
110 }
111
112 pub fn add_file(&mut self, role: ReceiptRole, path: impl AsRef<Path>) -> Result<(), IoError> {
114 self.entries.push(FileReceipt::from_path(role, path)?);
115 Ok(())
116 }
117
118 pub fn add_uri(&mut self, role: ReceiptRole, uri: impl Into<PathBuf>) {
120 self.entries.push(FileReceipt::from_uri(role, uri));
121 }
122
123 pub fn read_json(path: impl AsRef<Path>) -> Result<Self, IoError> {
125 let path = path.as_ref();
126 let text = std::fs::read_to_string(path).map_err(|error| {
127 IoError::Manifest(format!("cannot read dataset manifest `{}`: {error}", path.display()))
128 })?;
129 serde_json::from_str(&text).map_err(|error| {
130 IoError::Manifest(format!(
131 "cannot parse dataset manifest `{}`: {error}",
132 path.display()
133 ))
134 })
135 }
136
137 pub fn validate_local_files(&self) -> Result<ManifestValidation, IoError> {
139 if self.version != DATASET_MANIFEST_VERSION {
140 return Err(IoError::Manifest(format!(
141 "unsupported dataset manifest version {}; expected {}",
142 self.version, DATASET_MANIFEST_VERSION
143 )));
144 }
145 let mut checked_local_files = 0_u64;
146 let mut uri_entries = 0_u64;
147 let mut total_bytes = 0_u64;
148 for entry in &self.entries {
149 match (&entry.size_bytes, &entry.sha256) {
150 (Some(expected_size), Some(expected_sha256)) => {
151 let actual = FileReceipt::from_path(entry.role, &entry.path)?;
152 if actual.size_bytes != entry.size_bytes {
153 return Err(IoError::Manifest(format!(
154 "size mismatch for `{}`: expected {:?}, observed {:?}",
155 entry.path.display(),
156 entry.size_bytes,
157 actual.size_bytes
158 )));
159 }
160 if actual.sha256.as_deref() != Some(expected_sha256.as_str()) {
161 return Err(IoError::Manifest(format!(
162 "checksum mismatch for `{}`: expected {}, observed {}",
163 entry.path.display(),
164 expected_sha256,
165 actual.sha256.as_deref().unwrap_or("<missing>")
166 )));
167 }
168 checked_local_files = checked_local_files.checked_add(1).ok_or_else(|| {
169 IoError::Manifest(
170 "local file count overflow while validating manifest".into(),
171 )
172 })?;
173 total_bytes = total_bytes.checked_add(*expected_size).ok_or_else(|| {
174 IoError::Manifest(
175 "total byte count overflow while validating manifest".into(),
176 )
177 })?;
178 }
179 (None, None) if is_uri(&entry.path) => {
180 uri_entries = uri_entries.checked_add(1).ok_or_else(|| {
181 IoError::Manifest("URI count overflow while validating manifest".into())
182 })?;
183 }
184 (None, None) => {
185 return Err(IoError::Manifest(format!(
186 "manifest entry `{}` has no checksum and is not a URI",
187 entry.path.display()
188 )));
189 }
190 _ => {
191 return Err(IoError::Manifest(format!(
192 "manifest entry `{}` has a partial size/checksum receipt",
193 entry.path.display()
194 )));
195 }
196 }
197 }
198 Ok(ManifestValidation { checked_local_files, uri_entries, total_bytes })
199 }
200
201 pub fn to_json(&self) -> Result<String, IoError> {
203 serde_json::to_string_pretty(self).map_err(|error| {
204 IoError::Manifest(format!("cannot serialize dataset manifest: {error}"))
205 })
206 }
207
208 pub fn write_json(&self, path: impl AsRef<Path>) -> Result<(), IoError> {
210 let path = path.as_ref();
211 if let Some(parent) = path.parent() {
212 if !parent.as_os_str().is_empty() {
213 std::fs::create_dir_all(parent)?;
214 }
215 }
216 std::fs::write(path, format!("{}\n", self.to_json()?))?;
217 Ok(())
218 }
219}
220
221fn hex_digest(bytes: &[u8]) -> String {
222 let mut output = String::with_capacity(bytes.len() * 2);
223 for byte in bytes {
224 output.push_str(&format!("{byte:02x}"));
225 }
226 output
227}
228
229fn is_uri(path: &Path) -> bool {
230 path.to_string_lossy().contains("://")
231}
232
233#[cfg(test)]
234mod tests {
235 use super::{DatasetManifest, FileReceipt, ReceiptRole, DATASET_MANIFEST_VERSION};
236
237 #[test]
238 fn hashes_file_and_records_size() {
239 let directory = tempfile::tempdir().unwrap();
240 let path = directory.path().join("scan.bin");
241 std::fs::write(&path, b"hello").unwrap();
242
243 let receipt = FileReceipt::from_path(ReceiptRole::Input, &path).unwrap();
244 assert_eq!(receipt.size_bytes, Some(5));
245 assert_eq!(
246 receipt.sha256.as_deref(),
247 Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
248 );
249 }
250
251 #[test]
252 fn serializes_local_and_uri_entries() {
253 let directory = tempfile::tempdir().unwrap();
254 let path = directory.path().join("scan.bin");
255 std::fs::write(&path, b"data").unwrap();
256
257 let mut manifest = DatasetManifest::new();
258 manifest.add_file(ReceiptRole::Input, &path).unwrap();
259 manifest.add_uri(ReceiptRole::Auxiliary, "https://example.test/scan.copc.laz");
260 let json = manifest.to_json().unwrap();
261 let decoded: DatasetManifest = serde_json::from_str(&json).unwrap();
262
263 assert_eq!(decoded.version, DATASET_MANIFEST_VERSION);
264 assert_eq!(decoded.entries.len(), 2);
265 assert_eq!(decoded.entries[0].role, ReceiptRole::Input);
266 assert_eq!(decoded.entries[0].size_bytes, Some(4));
267 assert_eq!(decoded.entries[1].sha256, None);
268 let validation = decoded.validate_local_files().unwrap();
269 assert_eq!(validation.checked_local_files, 1);
270 assert_eq!(validation.uri_entries, 1);
271 assert_eq!(validation.total_bytes, 4);
272
273 let manifest_path = directory.path().join("manifest.json");
274 decoded.write_json(&manifest_path).unwrap();
275 assert_eq!(DatasetManifest::read_json(&manifest_path).unwrap(), decoded);
276 }
277
278 #[test]
279 fn rejects_changed_local_file() {
280 let directory = tempfile::tempdir().unwrap();
281 let path = directory.path().join("scan.bin");
282 std::fs::write(&path, b"before").unwrap();
283 let mut manifest = DatasetManifest::new();
284 manifest.add_file(ReceiptRole::Input, &path).unwrap();
285 std::fs::write(&path, b"after").unwrap();
286
287 let error = manifest.validate_local_files().unwrap_err();
288 assert!(error.to_string().contains("mismatch"));
289 }
290
291 #[test]
292 fn rejects_partial_or_unchecked_local_receipts() {
293 let path = std::path::PathBuf::from("/tmp/scan.bin");
294 let partial = DatasetManifest {
295 version: DATASET_MANIFEST_VERSION,
296 entries: vec![FileReceipt {
297 role: ReceiptRole::Input,
298 path: path.clone(),
299 size_bytes: Some(1),
300 sha256: None,
301 }],
302 };
303 assert!(partial.validate_local_files().unwrap_err().to_string().contains("partial"));
304
305 let unchecked = DatasetManifest {
306 version: DATASET_MANIFEST_VERSION,
307 entries: vec![FileReceipt {
308 role: ReceiptRole::Input,
309 path,
310 size_bytes: None,
311 sha256: None,
312 }],
313 };
314 assert!(unchecked.validate_local_files().unwrap_err().to_string().contains("not a URI"));
315 }
316}