1use std::{
15 io::{BufReader, ErrorKind},
16 path::{Path, PathBuf},
17};
18
19use digest::Digest;
20use rattler_conda_types::package::{IndexJson, PackageFile, PathType, PathsEntry, PathsJson};
21use rattler_digest::Sha256;
22use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
23use rayon::prelude::IndexedParallelIterator;
24
25#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
27pub enum ValidationMode {
28 #[default]
32 Skip,
33
34 Fast,
36
37 Full,
39}
40
41#[derive(Debug, thiserror::Error)]
44pub enum PackageValidationError {
45 #[error("neither a 'paths.json' or a deprecated 'files' file was found")]
47 MetadataMissing,
48
49 #[error("failed to read 'paths.json' file")]
51 ReadPathsJsonError(#[source] std::io::Error),
52
53 #[error("failed to read validation data from deprecated files")]
55 ReadDeprecatedPathsJsonError(#[source] std::io::Error),
56
57 #[error("the path '{0}' seems to be corrupted")]
59 CorruptedEntry(PathBuf, #[source] PackageEntryValidationError),
60
61 #[error("failed to read 'index.json'")]
63 ReadIndexJsonError(#[source] std::io::Error),
64}
65
66#[derive(Debug, thiserror::Error)]
69pub enum PackageEntryValidationError {
70 #[error("failed to retrieve file metadata'")]
72 GetMetadataFailed(#[source] std::io::Error),
73
74 #[error("the file does not exist")]
76 NotFound,
77
78 #[error("expected a symbolic link")]
80 ExpectedSymlink,
81
82 #[error("expected a directory")]
84 ExpectedDirectory,
85
86 #[error("incorrect size, expected {0} but file on disk is {1}")]
88 IncorrectSize(u64, u64),
89
90 #[error("an io error occurred")]
92 IoError(#[from] std::io::Error),
93
94 #[error("sha256 hash mismatch, expected '{0}' but file on disk is '{1}'")]
96 HashMismatch(String, String),
97}
98
99pub fn validate_package_directory(
109 package_dir: &Path,
110 mode: ValidationMode,
111) -> Result<(IndexJson, PathsJson), PackageValidationError> {
112 let index_json = IndexJson::from_package_directory(package_dir)
114 .map_err(PackageValidationError::ReadIndexJsonError)?;
115
116 let paths = match PathsJson::from_package_directory(package_dir) {
120 Err(e) if e.kind() == ErrorKind::NotFound => {
121 match PathsJson::from_deprecated_package_directory(package_dir) {
122 Ok(paths) => paths,
123 Err(e) if e.kind() == ErrorKind::NotFound => {
124 return Err(PackageValidationError::MetadataMissing)
125 }
126 Err(e) => return Err(PackageValidationError::ReadDeprecatedPathsJsonError(e)),
127 }
128 }
129 Err(e) => return Err(PackageValidationError::ReadPathsJsonError(e)),
130 Ok(paths) => paths,
131 };
132
133 if mode == ValidationMode::Skip {
136 return Ok((index_json, paths));
137 }
138
139 validate_package_directory_from_paths(package_dir, &paths, mode)
141 .map_err(|(path, err)| PackageValidationError::CorruptedEntry(path, err))?;
142
143 Ok((index_json, paths))
144}
145
146pub fn validate_package_directory_from_paths(
149 package_dir: &Path,
150 paths: &PathsJson,
151 mode: ValidationMode,
152) -> Result<(), (PathBuf, PackageEntryValidationError)> {
153 paths
155 .paths
156 .par_iter()
157 .with_min_len(1000)
158 .try_for_each(|entry| {
159 validate_package_entry(package_dir, entry, mode)
160 .map_err(|e| (entry.relative_path.clone(), e))
161 })
162}
163
164fn validate_package_entry(
167 package_dir: &Path,
168 entry: &PathsEntry,
169 mode: ValidationMode,
170) -> Result<(), PackageEntryValidationError> {
171 let path = package_dir.join(&entry.relative_path);
172
173 match entry.path_type {
175 PathType::HardLink => validate_package_hard_link_entry(path, entry, mode),
176 PathType::SoftLink => validate_package_soft_link_entry(path, entry, mode),
177 PathType::Directory => validate_package_directory_entry(path, entry, mode),
178 }
179}
180
181fn validate_package_hard_link_entry(
184 path: PathBuf,
185 entry: &PathsEntry,
186 mode: ValidationMode,
187) -> Result<(), PackageEntryValidationError> {
188 debug_assert!(entry.path_type == PathType::HardLink);
189
190 if mode == ValidationMode::Fast {
191 if !path.is_file() {
192 return Err(PackageEntryValidationError::NotFound);
193 }
194 return Ok(());
195 }
196
197 if entry.sha256.is_none() && entry.size_in_bytes.is_none() {
199 if !path.is_file() {
200 return Err(PackageEntryValidationError::NotFound);
201 }
202 return Ok(());
203 }
204
205 let file = match std::fs::File::open(&path) {
207 Ok(file) => file,
208 Err(e) if e.kind() == ErrorKind::NotFound => {
209 return Err(PackageEntryValidationError::NotFound);
210 }
211 Err(e) => return Err(PackageEntryValidationError::IoError(e)),
212 };
213
214 if let Some(size_in_bytes) = entry.size_in_bytes {
216 let actual_file_len = file
217 .metadata()
218 .map_err(PackageEntryValidationError::IoError)?
219 .len();
220 if size_in_bytes != actual_file_len {
221 return Err(PackageEntryValidationError::IncorrectSize(
222 size_in_bytes,
223 actual_file_len,
224 ));
225 }
226 }
227
228 if let Some(expected_hash) = &entry.sha256 {
230 let mut file = BufReader::with_capacity(64 * 1024, file);
232 let mut hasher = Sha256::default();
233 std::io::copy(&mut file, &mut hasher)?;
234 let hash = hasher.finalize();
235
236 if expected_hash != &hash {
238 return Err(PackageEntryValidationError::HashMismatch(
239 format!("{expected_hash:x}"),
240 format!("{hash:x}"),
241 ));
242 }
243 }
244
245 Ok(())
246}
247
248fn validate_package_soft_link_entry(
251 path: PathBuf,
252 entry: &PathsEntry,
253 _mode: ValidationMode,
254) -> Result<(), PackageEntryValidationError> {
255 debug_assert!(entry.path_type == PathType::SoftLink);
256
257 if !path.is_symlink() {
258 return Err(PackageEntryValidationError::ExpectedSymlink);
259 }
260
261 Ok(())
268}
269
270fn validate_package_directory_entry(
273 path: PathBuf,
274 entry: &PathsEntry,
275 _mode: ValidationMode,
276) -> Result<(), PackageEntryValidationError> {
277 debug_assert!(entry.path_type == PathType::Directory);
278
279 if path.is_dir() {
280 Ok(())
281 } else {
282 Err(PackageEntryValidationError::ExpectedDirectory)
283 }
284}
285
286#[cfg(test)]
287mod test {
288 use std::io::Write;
289
290 use assert_matches::assert_matches;
291 use rattler_conda_types::package::{PackageFile, PathType, PathsJson};
292 use rstest::rstest;
293 use url::Url;
294
295 use super::{
296 validate_package_directory, validate_package_directory_from_paths,
297 PackageEntryValidationError, PackageValidationError, ValidationMode,
298 };
299
300 #[rstest]
301 #[case::conda(
302 "https://conda.anaconda.org/conda-forge/win-64/conda-22.9.0-py38haa244fe_2.tar.bz2",
303 "3c2c2e8e81bde5fb1ac4b014f51a62411feff004580c708c97a0ec2b7058cdc4"
304 )]
305 #[case::mamba(
306 "https://conda.anaconda.org/conda-forge/win-64/mamba-1.0.0-py38hecfeebb_2.tar.bz2",
307 "f44c4bc9c6916ecc0e33137431645b029ade22190c7144eead61446dcbcc6f97"
308 )]
309 #[case::conda(
310 "https://conda.anaconda.org/conda-forge/win-64/conda-22.11.1-py38haa244fe_1.conda",
311 "a8a44c5ff2b2f423546d49721ba2e3e632233c74a813c944adf8e5742834930e"
312 )]
313 #[case::mamba(
314 "https://conda.anaconda.org/conda-forge/win-64/mamba-1.1.0-py39hb3d9227_2.conda",
315 "c172acdf9cb7655dd224879b30361a657b09bb084b65f151e36a2b51e51a080a"
316 )]
317 fn test_validate_package_files(#[case] url: Url, #[case] sha256: &str) {
318 let temp_dir = tempfile::tempdir().unwrap();
320 let package_path = tools::download_and_cache_file(url, sha256).unwrap();
321
322 rattler_package_streaming::fs::extract(&package_path, temp_dir.path()).unwrap();
323
324 let result = validate_package_directory(temp_dir.path(), ValidationMode::Full);
327 if let Err(e) = result {
328 panic!("{e}");
329 }
330
331 let paths = PathsJson::from_package_directory(temp_dir.path())
333 .or_else(|_| PathsJson::from_deprecated_package_directory(temp_dir.path()))
334 .unwrap();
335 let entry = paths
336 .paths
337 .iter()
338 .find(|e| e.path_type == PathType::HardLink)
339 .expect("package does not contain a file");
340
341 let mut file = std::fs::OpenOptions::new()
343 .write(true)
344 .open(temp_dir.path().join(&entry.relative_path))
345 .unwrap();
346 file.write_all(&[255]).unwrap();
347 drop(file);
348
349 assert_matches!(
352 validate_package_directory_from_paths(temp_dir.path(), &paths, ValidationMode::Full),
353 Err((
354 path,
355 PackageEntryValidationError::HashMismatch(_, _)
356 )) if path == entry.relative_path
357 );
358 }
359
360 #[rstest]
361 #[cfg(unix)]
362 #[case::mamba(
363 "https://conda.anaconda.org/conda-forge/linux-ppc64le/python-3.10.6-h2c4edbf_0_cpython.tar.bz2",
364 "978c122f6529cb617b90e6e692308a5945bf9c3ba0c27acbe4bea4c8b02cdad0"
365 )]
366 #[case::mamba(
368 "https://conda.anaconda.org/conda-forge/linux-64/zlib-1.2.8-3.tar.bz2",
369 "85fcb6906b8686fe6341db89b4e6fc2631ad69ee6eab2f4823bfd64ae0b20ac8"
370 )]
371 fn test_validate_package_files_symlink(#[case] url: Url, #[case] sha256: &str) {
372 let temp_dir = tempfile::tempdir().unwrap();
374 let package_path = tools::download_and_cache_file(url, sha256).unwrap();
375
376 rattler_package_streaming::fs::extract(&package_path, temp_dir.path()).unwrap();
377
378 let result = validate_package_directory(temp_dir.path(), ValidationMode::Full);
381 if let Err(e) = result {
382 panic!("{e}");
383 }
384
385 let paths = PathsJson::from_package_directory(temp_dir.path())
387 .or_else(|_| PathsJson::from_deprecated_package_directory(temp_dir.path()))
388 .unwrap();
389 let entry = paths
390 .paths
391 .iter()
392 .find(|e| e.path_type == PathType::SoftLink)
393 .expect("package does not contain a file");
394
395 let entry_path = temp_dir.path().join(&entry.relative_path);
397 let contents = std::fs::read(&entry_path).unwrap();
398 std::fs::remove_file(&entry_path).unwrap();
399 std::fs::write(entry_path, contents).unwrap();
400
401 assert_matches!(
403 validate_package_directory_from_paths(temp_dir.path(), &paths, ValidationMode::Full),
404 Err((
405 path,
406 PackageEntryValidationError::ExpectedSymlink
407 )) if path == entry.relative_path
408 );
409 }
410
411 #[test]
412 fn test_missing_metadata() {
413 let temp_dir = tempfile::tempdir().unwrap();
414 assert_matches!(
415 validate_package_directory(temp_dir.path(), ValidationMode::Full),
416 Err(PackageValidationError::ReadIndexJsonError(_))
417 );
418 }
419}