1use std::{
2 collections::BTreeSet,
3 env, fs,
4 fs::{File, OpenOptions},
5 io::{Read, Write},
6 path::{Component, Path, PathBuf},
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::{Error, Result};
15
16use super::{DATASET_FORMAT_VERSION, Dataset, DatasetLoader};
17
18pub const DATASET_REGISTRY_VERSION: u32 = 1;
20pub const DEFAULT_DATASET_REGISTRY_URL: &str =
22 "https://raw.githubusercontent.com/hgrecco/fpm-rs/main/dataset_registry.json";
23pub const DATASET_REGISTRY_URL_ENV: &str = "FPM_RS_DATASET_REGISTRY_URL";
25pub const DATASET_CACHE_DIR_ENV: &str = "FPM_RS_DATASET_CACHE_DIR";
27
28const CACHE_MARKER: &str = ".fpm-rs-dataset-cache";
29const CACHE_MARKER_CONTENTS: &[u8] = b"fpm-rs managed dataset cache v1\n";
30const INSTALL_METADATA: &str = ".fpm-rs-install.json";
31const MAX_REGISTRY_BYTES: usize = 16 * 1024 * 1024;
32const MAX_ARCHIVE_ENTRIES: u64 = 100_000;
33const MAX_EXTRACTED_BYTES: u64 = 256 * 1024 * 1024 * 1024;
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct DatasetArchive {
39 pub url: String,
41 pub sha256: String,
43 pub size_bytes: u64,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct DatasetLicense {
51 pub spdx: String,
53 pub url: String,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct DatasetCitation {
61 pub doi: String,
63 pub text: String,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct DatasetSource {
71 pub url: String,
73 pub description: String,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct DatasetRegistryEntry {
81 pub id: String,
83 pub version: String,
85 pub title: String,
87 pub description: String,
89 pub format_version: u32,
91 pub archive: DatasetArchive,
93 pub license: DatasetLicense,
95 pub citation: DatasetCitation,
97 pub source: DatasetSource,
99 pub tags: Vec<String>,
101}
102
103impl DatasetRegistryEntry {
104 pub fn validate(&self) -> Result<()> {
106 validate_component("dataset id", &self.id)?;
107 validate_component("dataset version", &self.version)?;
108 for (label, value) in [
109 ("dataset title", self.title.as_str()),
110 ("dataset description", self.description.as_str()),
111 ("archive URL", self.archive.url.as_str()),
112 ("license SPDX identifier", self.license.spdx.as_str()),
113 ("license URL", self.license.url.as_str()),
114 ("citation DOI", self.citation.doi.as_str()),
115 ("citation text", self.citation.text.as_str()),
116 ("source URL", self.source.url.as_str()),
117 ("source description", self.source.description.as_str()),
118 ] {
119 validate_nonempty(label, value)?;
120 }
121 if self.format_version != DATASET_FORMAT_VERSION {
122 return Err(Error::Dataset(format!(
123 "dataset '{}' declares unsupported format version {}; expected {}",
124 self.id, self.format_version, DATASET_FORMAT_VERSION
125 )));
126 }
127 if self.archive.size_bytes == 0 {
128 return Err(Error::Dataset(format!(
129 "dataset '{}' archive size must be positive",
130 self.id
131 )));
132 }
133 if self.archive.sha256.len() != 64
134 || !self
135 .archive
136 .sha256
137 .bytes()
138 .all(|byte| byte.is_ascii_hexdigit())
139 {
140 return Err(Error::Dataset(format!(
141 "dataset '{}' SHA-256 must contain 64 hexadecimal characters",
142 self.id
143 )));
144 }
145 let archive_path = self
146 .archive
147 .url
148 .split(['?', '#'])
149 .next()
150 .unwrap_or_default();
151 if !archive_path.ends_with(".tar.zst") {
152 return Err(Error::Dataset(format!(
153 "dataset '{}' archive URL must identify a .tar.zst file",
154 self.id
155 )));
156 }
157 if self.tags.is_empty() {
158 return Err(Error::Dataset(format!(
159 "dataset '{}' must contain at least one tag",
160 self.id
161 )));
162 }
163 let mut tags = BTreeSet::new();
164 for tag in &self.tags {
165 validate_nonempty("dataset tag", tag)?;
166 if !tags.insert(tag) {
167 return Err(Error::Dataset(format!(
168 "dataset '{}' contains duplicate tag '{tag}'",
169 self.id
170 )));
171 }
172 }
173 Ok(())
174 }
175}
176
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct DatasetRegistryDocument {
181 pub registry_version: u32,
183 pub datasets: Vec<DatasetRegistryEntry>,
185}
186
187impl DatasetRegistryDocument {
188 pub fn from_slice(bytes: &[u8]) -> Result<Self> {
190 let document: Self = serde_json::from_slice(bytes)?;
191 document.validate()?;
192 Ok(document)
193 }
194
195 pub fn validate(&self) -> Result<()> {
197 if self.registry_version != DATASET_REGISTRY_VERSION {
198 return Err(Error::Dataset(format!(
199 "unsupported dataset registry version {}; expected {}",
200 self.registry_version, DATASET_REGISTRY_VERSION
201 )));
202 }
203 let mut identifiers = BTreeSet::new();
204 for entry in &self.datasets {
205 entry.validate()?;
206 if !identifiers.insert(&entry.id) {
207 return Err(Error::Dataset(format!(
208 "dataset registry contains duplicate id '{}'",
209 entry.id
210 )));
211 }
212 }
213 Ok(())
214 }
215
216 fn entry(&self, id: &str) -> Result<&DatasetRegistryEntry> {
217 self.datasets
218 .iter()
219 .find(|entry| entry.id == id)
220 .ok_or_else(|| Error::Dataset(format!("dataset registry has no entry '{id}'")))
221 }
222}
223
224#[derive(Clone, Debug, PartialEq, Eq)]
226pub struct DatasetListing {
227 pub entry: DatasetRegistryEntry,
229 pub cached: bool,
231 pub cache_path: Option<PathBuf>,
233}
234
235#[derive(Clone, Debug)]
237pub struct DatasetRegistry {
238 registry_url: String,
239 cache_dir: PathBuf,
240}
241
242impl DatasetRegistry {
243 pub fn from_defaults() -> Result<Self> {
246 let registry_url = env::var(DATASET_REGISTRY_URL_ENV)
247 .ok()
248 .filter(|value| !value.trim().is_empty())
249 .unwrap_or_else(|| DEFAULT_DATASET_REGISTRY_URL.to_owned());
250 let cache_dir = env::var_os(DATASET_CACHE_DIR_ENV)
251 .filter(|value| !value.is_empty())
252 .map(PathBuf::from)
253 .or_else(|| {
254 dirs::cache_dir().map(|directory| directory.join("fpm-rs").join("datasets"))
255 })
256 .ok_or_else(|| {
257 Error::Dataset("could not determine the platform cache directory".into())
258 })?;
259 Self::new(registry_url, cache_dir)
260 }
261
262 pub fn new(registry_url: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Result<Self> {
264 let registry_url = registry_url.into();
265 validate_nonempty("dataset registry URL", ®istry_url)?;
266 let cache_dir = cache_dir.into();
267 if cache_dir.as_os_str().is_empty() {
268 return Err(Error::Dataset(
269 "dataset cache directory must not be empty".into(),
270 ));
271 }
272 Ok(Self {
273 registry_url,
274 cache_dir,
275 })
276 }
277
278 pub fn registry_url(&self) -> &str {
280 &self.registry_url
281 }
282
283 pub fn cache_dir(&self) -> &Path {
285 &self.cache_dir
286 }
287
288 pub fn list(&self) -> Result<Vec<DatasetListing>> {
290 let registry = self.load_registry()?;
291 let _lock = self.lock_cache()?;
292 registry
293 .datasets
294 .into_iter()
295 .map(|entry| {
296 let path = self.dataset_path(&entry);
297 let cached = self.is_cached(&entry)?;
298 Ok(DatasetListing {
299 entry,
300 cached,
301 cache_path: cached.then_some(path),
302 })
303 })
304 .collect()
305 }
306
307 pub fn download(&self, id: &str) -> Result<PathBuf> {
311 validate_component("dataset id", id)?;
312 let registry = self.load_registry()?;
313 let entry = registry.entry(id)?.clone();
314 self.install(&entry)
315 }
316
317 pub fn download_all(&self) -> Result<Vec<PathBuf>> {
319 let registry = self.load_registry()?;
320 registry
321 .datasets
322 .iter()
323 .map(|entry| self.install(entry))
324 .collect()
325 }
326
327 pub fn open(&self, id: &str) -> Result<Dataset> {
331 let path = self.download(id)?;
332 match self.load_cached_dataset(&path) {
333 Ok(dataset) => Ok(dataset),
334 Err(first_error) => {
335 let registry = self.load_registry()?;
336 let entry = registry.entry(id)?.clone();
337 {
338 let _lock = self.lock_cache()?;
339 if path.exists() {
340 self.validate_managed_tree(&path)?;
341 fs::remove_dir_all(&path)?;
342 }
343 }
344 let repaired = self.install(&entry)?;
345 self.load_cached_dataset(&repaired).map_err(|second_error| {
346 Error::Dataset(format!(
347 "cached dataset '{id}' was invalid ({first_error}); reinstall also failed: {second_error}"
348 ))
349 })
350 }
351 }
352 }
353
354 fn load_cached_dataset(&self, path: &Path) -> Result<Dataset> {
355 let _lock = self.lock_cache()?;
356 DatasetLoader::new(path)?.load()
357 }
358
359 pub fn clean(&self, id: &str) -> Result<bool> {
361 validate_component("dataset id", id)?;
362 let _lock = self.lock_cache()?;
363 if !self.cache_dir.exists() {
364 return Ok(false);
365 }
366 self.validate_cache_root()?;
367 let path = self.cache_dir.join("datasets").join(id);
368 if !path.exists() {
369 return Ok(false);
370 }
371 self.validate_managed_tree(&path)?;
372 fs::remove_dir_all(path)?;
373 Ok(true)
374 }
375
376 pub fn clean_all(&self) -> Result<usize> {
378 let _lock = self.lock_cache()?;
379 if !self.cache_dir.exists() {
380 return Ok(0);
381 }
382 self.validate_cache_root()?;
383 self.validate_managed_tree(&self.cache_dir)?;
384 let count = count_cached_versions(&self.cache_dir.join("datasets"))?;
385 fs::remove_dir_all(&self.cache_dir)?;
386 Ok(count)
387 }
388
389 fn load_registry(&self) -> Result<DatasetRegistryDocument> {
390 match read_source_limited(&self.registry_url, MAX_REGISTRY_BYTES as u64) {
391 Ok(bytes) => {
392 let registry = DatasetRegistryDocument::from_slice(&bytes)?;
393 self.write_registry_snapshot(&bytes)?;
394 Ok(registry)
395 }
396 Err(fetch_error) => match self.read_registry_snapshot() {
397 Ok(bytes) => DatasetRegistryDocument::from_slice(&bytes),
398 Err(snapshot_error) => Err(Error::Dataset(format!(
399 "failed to fetch registry '{}': {fetch_error}; no usable cached snapshot: {snapshot_error}",
400 self.registry_url
401 ))),
402 },
403 }
404 }
405
406 fn install(&self, entry: &DatasetRegistryEntry) -> Result<PathBuf> {
407 let _lock = self.lock_cache()?;
408 self.ensure_cache_root()?;
409 let destination = self.dataset_path(entry);
410 if self.is_cached(entry)? {
411 return Ok(destination);
412 }
413 if destination.exists() {
414 self.validate_managed_tree(&destination)?;
415 fs::remove_dir_all(&destination)?;
416 }
417
418 let partial_root = self.cache_dir.join("partial");
419 fs::create_dir_all(&partial_root)?;
420 let nonce = unique_nonce();
421 let archive_path = partial_root.join(format!("{}-{nonce}.tar.zst", entry.id));
422 let staging_path = partial_root.join(format!("{}-{nonce}.bundle", entry.id));
423 fs::create_dir(&staging_path)?;
424
425 let result = (|| {
426 download_verified_archive(entry, &archive_path)?;
427 extract_archive(&archive_path, &staging_path)?;
428 if !staging_path.join("dataset.json").is_file() {
429 return Err(Error::Dataset(format!(
430 "dataset '{}' archive must contain dataset.json at its root",
431 entry.id
432 )));
433 }
434 DatasetLoader::new(&staging_path)?.load()?;
435 let metadata = InstallMetadata {
436 cache_format_version: 1,
437 id: entry.id.clone(),
438 version: entry.version.clone(),
439 archive_sha256: entry.archive.sha256.to_ascii_lowercase(),
440 };
441 serde_json::to_writer_pretty(
442 File::create(staging_path.join(INSTALL_METADATA))?,
443 &metadata,
444 )?;
445 let parent = destination
446 .parent()
447 .ok_or_else(|| Error::Dataset("invalid destination for cached dataset".into()))?;
448 fs::create_dir_all(parent)?;
449 fs::rename(&staging_path, &destination)?;
450 Ok(destination.clone())
451 })();
452
453 let _ = fs::remove_file(&archive_path);
454 if staging_path.exists() {
455 let _ = fs::remove_dir_all(&staging_path);
456 }
457 result
458 }
459
460 fn dataset_path(&self, entry: &DatasetRegistryEntry) -> PathBuf {
461 self.cache_dir
462 .join("datasets")
463 .join(&entry.id)
464 .join(&entry.version)
465 }
466
467 fn is_cached(&self, entry: &DatasetRegistryEntry) -> Result<bool> {
468 let path = self.dataset_path(entry);
469 if !path.is_dir() || !path.join("dataset.json").is_file() {
470 return Ok(false);
471 }
472 self.validate_managed_tree(&path)?;
473 let metadata_path = path.join(INSTALL_METADATA);
474 if !metadata_path.is_file() {
475 return Ok(false);
476 }
477 let Ok(metadata) =
478 serde_json::from_reader::<_, InstallMetadata>(File::open(metadata_path)?)
479 else {
480 return Ok(false);
481 };
482 Ok(metadata.cache_format_version == 1
483 && metadata.id == entry.id
484 && metadata.version == entry.version
485 && metadata.archive_sha256 == entry.archive.sha256.to_ascii_lowercase())
486 }
487
488 fn snapshot_path(&self) -> PathBuf {
489 let digest = format!("{:x}", Sha256::digest(self.registry_url.as_bytes()));
490 self.cache_dir
491 .join("registries")
492 .join(format!("{digest}.json"))
493 }
494
495 fn write_registry_snapshot(&self, bytes: &[u8]) -> Result<()> {
496 let _lock = self.lock_cache()?;
497 self.ensure_cache_root()?;
498 let path = self.snapshot_path();
499 let parent = path
500 .parent()
501 .ok_or_else(|| Error::Dataset("invalid registry snapshot path".into()))?;
502 fs::create_dir_all(parent)?;
503 let temporary = path.with_extension(format!("json.{}.partial", unique_nonce()));
504 fs::write(&temporary, bytes)?;
505 replace_file(&temporary, &path)?;
506 Ok(())
507 }
508
509 fn read_registry_snapshot(&self) -> Result<Vec<u8>> {
510 if !self.cache_dir.exists() {
511 return Err(Error::Dataset("dataset cache does not exist".into()));
512 }
513 self.validate_cache_root()?;
514 fs::read(self.snapshot_path()).map_err(Into::into)
515 }
516
517 fn ensure_cache_root(&self) -> Result<()> {
518 if !self.cache_dir.exists() {
519 fs::create_dir_all(&self.cache_dir)?;
520 }
521 let metadata = fs::symlink_metadata(&self.cache_dir)?;
522 if metadata.file_type().is_symlink() || !metadata.is_dir() {
523 return Err(Error::Dataset(format!(
524 "dataset cache root must be a real directory: {}",
525 self.cache_dir.display()
526 )));
527 }
528 let marker = self.cache_dir.join(CACHE_MARKER);
529 if marker.exists() {
530 return validate_marker(&marker);
531 }
532 if fs::read_dir(&self.cache_dir)?.next().is_some() {
533 return Err(Error::Dataset(format!(
534 "refusing to manage non-empty unmarked cache directory {}",
535 self.cache_dir.display()
536 )));
537 }
538 let mut file = OpenOptions::new()
539 .write(true)
540 .create_new(true)
541 .open(marker)?;
542 file.write_all(CACHE_MARKER_CONTENTS)?;
543 file.sync_all()?;
544 Ok(())
545 }
546
547 fn validate_cache_root(&self) -> Result<()> {
548 let metadata = fs::symlink_metadata(&self.cache_dir)?;
549 if metadata.file_type().is_symlink() || !metadata.is_dir() {
550 return Err(Error::Dataset(format!(
551 "dataset cache root must be a real directory: {}",
552 self.cache_dir.display()
553 )));
554 }
555 validate_marker(&self.cache_dir.join(CACHE_MARKER))
556 }
557
558 fn validate_managed_tree(&self, path: &Path) -> Result<()> {
559 let relative = path.strip_prefix(&self.cache_dir).map_err(|_| {
560 Error::Dataset(format!(
561 "managed dataset path escapes cache root: {}",
562 path.display()
563 ))
564 })?;
565 if relative
566 .components()
567 .any(|component| !matches!(component, Component::Normal(_)))
568 {
569 return Err(Error::Dataset(format!(
570 "managed dataset path is unsafe: {}",
571 path.display()
572 )));
573 }
574 reject_symlinks(path)
575 }
576
577 fn lock_cache(&self) -> Result<File> {
578 let parent = self.cache_dir.parent().ok_or_else(|| {
579 Error::Dataset("dataset cache root must have a parent directory".into())
580 })?;
581 fs::create_dir_all(parent)?;
582 let name = self
583 .cache_dir
584 .file_name()
585 .and_then(|value| value.to_str())
586 .unwrap_or("datasets");
587 let path = parent.join(format!(".{name}.lock"));
588 let file = OpenOptions::new()
589 .read(true)
590 .write(true)
591 .create(true)
592 .truncate(false)
593 .open(path)?;
594 FileExt::lock_exclusive(&file)?;
595 Ok(file)
596 }
597}
598
599pub fn open_dataset(id: &str) -> Result<Dataset> {
601 DatasetRegistry::from_defaults()?.open(id)
602}
603
604#[derive(Debug, Serialize, Deserialize)]
605#[serde(deny_unknown_fields)]
606struct InstallMetadata {
607 cache_format_version: u32,
608 id: String,
609 version: String,
610 archive_sha256: String,
611}
612
613fn download_verified_archive(entry: &DatasetRegistryEntry, destination: &Path) -> Result<()> {
614 let mut reader = open_source_reader(&entry.archive.url)?;
615 let mut output = OpenOptions::new()
616 .write(true)
617 .create_new(true)
618 .open(destination)?;
619 let mut digest = Sha256::new();
620 let mut buffer = [0_u8; 64 * 1024];
621 let mut total = 0_u64;
622 loop {
623 let count = reader.read(&mut buffer)?;
624 if count == 0 {
625 break;
626 }
627 total = total
628 .checked_add(count as u64)
629 .ok_or_else(|| Error::Dataset("downloaded archive size overflowed".into()))?;
630 if total > entry.archive.size_bytes {
631 return Err(Error::Dataset(format!(
632 "dataset '{}' archive exceeds declared size {} bytes",
633 entry.id, entry.archive.size_bytes
634 )));
635 }
636 digest.update(&buffer[..count]);
637 output.write_all(&buffer[..count])?;
638 }
639 output.sync_all()?;
640 if total != entry.archive.size_bytes {
641 return Err(Error::Dataset(format!(
642 "dataset '{}' archive size mismatch: expected {}, got {total}",
643 entry.id, entry.archive.size_bytes
644 )));
645 }
646 let actual = format!("{:x}", digest.finalize());
647 if actual != entry.archive.sha256.to_ascii_lowercase() {
648 return Err(Error::Dataset(format!(
649 "dataset '{}' SHA-256 mismatch: expected {}, got {actual}",
650 entry.id, entry.archive.sha256
651 )));
652 }
653 Ok(())
654}
655
656fn extract_archive(archive_path: &Path, destination: &Path) -> Result<()> {
657 let decoder = zstd::Decoder::new(File::open(archive_path)?)?;
658 let mut archive = tar::Archive::new(decoder);
659 let mut paths = BTreeSet::new();
660 let mut remaining_entries = MAX_ARCHIVE_ENTRIES;
661 let mut remaining_bytes = MAX_EXTRACTED_BYTES;
662 for entry in archive.entries()? {
663 let mut entry = entry?;
664 remaining_entries = remaining_entries.checked_sub(1).ok_or_else(|| {
665 Error::Dataset("dataset archive exceeds the extraction entry limit".into())
666 })?;
667 let relative = entry.path()?.into_owned();
668 validate_archive_path(&relative)?;
669 if !paths.insert(relative.clone()) {
670 return Err(Error::Dataset(format!(
671 "dataset archive contains duplicate path {}",
672 relative.display()
673 )));
674 }
675 let entry_type = entry.header().entry_type();
676 let target = destination.join(&relative);
677 if entry_type.is_dir() {
678 fs::create_dir_all(target)?;
679 continue;
680 }
681 if !entry_type.is_file() {
682 return Err(Error::Dataset(format!(
683 "dataset archive entry is not a regular file or directory: {}",
684 relative.display()
685 )));
686 }
687 let size = entry.size();
688 remaining_bytes = remaining_bytes.checked_sub(size).ok_or_else(|| {
689 Error::Dataset("dataset archive exceeds the extraction byte limit".into())
690 })?;
691 if let Some(parent) = target.parent() {
692 fs::create_dir_all(parent)?;
693 }
694 let mut output = OpenOptions::new()
695 .write(true)
696 .create_new(true)
697 .open(&target)?;
698 let copied = std::io::copy(&mut entry, &mut output)?;
699 if copied != size {
700 return Err(Error::Dataset(format!(
701 "dataset archive entry size mismatch for {}",
702 relative.display()
703 )));
704 }
705 }
706 Ok(())
707}
708
709fn read_source_limited(source: &str, limit: u64) -> Result<Vec<u8>> {
710 let mut reader = open_source_reader(source)?;
711 let mut bytes = Vec::new();
712 let copied = std::io::copy(&mut reader.by_ref().take(limit + 1), &mut bytes)?;
713 if copied > limit {
714 return Err(Error::Dataset(format!(
715 "source exceeds the {limit}-byte limit: {source}"
716 )));
717 }
718 Ok(bytes)
719}
720
721fn open_source_reader(source: &str) -> Result<Box<dyn Read>> {
722 if source.starts_with("http://") || source.starts_with("https://") {
723 let response = ureq::get(source)
724 .call()
725 .map_err(|error| Error::Dataset(format!("request failed for {source}: {error}")))?;
726 return Ok(Box::new(response.into_body().into_reader()));
727 }
728 let path = source.strip_prefix("file://").unwrap_or(source);
729 Ok(Box::new(File::open(path).map_err(|error| {
730 Error::Dataset(format!("failed to open source {source}: {error}"))
731 })?))
732}
733
734fn validate_archive_path(path: &Path) -> Result<()> {
735 if path.as_os_str().is_empty()
736 || path.is_absolute()
737 || path
738 .components()
739 .any(|component| !matches!(component, Component::Normal(_)))
740 {
741 return Err(Error::Dataset(format!(
742 "dataset archive entry path is unsafe: {}",
743 path.display()
744 )));
745 }
746 Ok(())
747}
748
749fn validate_component(label: &str, value: &str) -> Result<()> {
750 if value.is_empty()
751 || value == "."
752 || value == ".."
753 || !value
754 .bytes()
755 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
756 {
757 return Err(Error::Dataset(format!(
758 "{label} must contain only letters, digits, '.', '_', or '-'"
759 )));
760 }
761 Ok(())
762}
763
764fn validate_nonempty(label: &str, value: &str) -> Result<()> {
765 if value.trim().is_empty() {
766 return Err(Error::Dataset(format!("{label} must not be empty")));
767 }
768 Ok(())
769}
770
771fn validate_marker(path: &Path) -> Result<()> {
772 let metadata = fs::symlink_metadata(path).map_err(|error| {
773 Error::Dataset(format!(
774 "dataset cache ownership marker is missing at {}: {error}",
775 path.display()
776 ))
777 })?;
778 if metadata.file_type().is_symlink() || !metadata.is_file() {
779 return Err(Error::Dataset(format!(
780 "dataset cache ownership marker must be a regular file: {}",
781 path.display()
782 )));
783 }
784 if fs::read(path)? != CACHE_MARKER_CONTENTS {
785 return Err(Error::Dataset(format!(
786 "dataset cache ownership marker is invalid: {}",
787 path.display()
788 )));
789 }
790 Ok(())
791}
792
793fn reject_symlinks(path: &Path) -> Result<()> {
794 let metadata = fs::symlink_metadata(path)?;
795 if metadata.file_type().is_symlink() {
796 return Err(Error::Dataset(format!(
797 "managed cache path contains a symlink: {}",
798 path.display()
799 )));
800 }
801 if metadata.is_dir() {
802 for entry in fs::read_dir(path)? {
803 reject_symlinks(&entry?.path())?;
804 }
805 }
806 Ok(())
807}
808
809fn count_cached_versions(root: &Path) -> Result<usize> {
810 if !root.is_dir() {
811 return Ok(0);
812 }
813 let mut count = 0;
814 for dataset in fs::read_dir(root)? {
815 let dataset = dataset?;
816 if dataset.file_type()?.is_dir() {
817 count += fs::read_dir(dataset.path())?
818 .filter_map(std::result::Result::ok)
819 .filter_map(|entry| entry.file_type().ok())
820 .filter(|kind| kind.is_dir())
821 .count();
822 }
823 }
824 Ok(count)
825}
826
827fn unique_nonce() -> String {
828 let nanos = SystemTime::now()
829 .duration_since(UNIX_EPOCH)
830 .map_or(0, |duration| duration.as_nanos());
831 format!("{}-{nanos}", std::process::id())
832}
833
834fn replace_file(source: &Path, destination: &Path) -> Result<()> {
835 match fs::rename(source, destination) {
836 Ok(()) => Ok(()),
837 Err(error)
838 if destination.exists()
839 && matches!(
840 error.kind(),
841 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
842 ) =>
843 {
844 fs::remove_file(destination)?;
845 fs::rename(source, destination)?;
846 Ok(())
847 }
848 Err(error) => Err(error.into()),
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 #[test]
857 fn archive_paths_reject_escape_and_non_normal_components() {
858 for path in [
859 "",
860 ".",
861 "./dataset.json",
862 "../dataset.json",
863 "/dataset.json",
864 ] {
865 assert!(
866 validate_archive_path(Path::new(path)).is_err(),
867 "accepted {path:?}"
868 );
869 }
870 assert!(validate_archive_path(Path::new("frames/frame.tiff")).is_ok());
871 }
872
873 #[test]
874 fn archive_extraction_rejects_links_and_duplicate_paths() -> Result<()> {
875 let temporary = tempfile::tempdir()?;
876 let link_archive = temporary.path().join("link.tar.zst");
877 {
878 let encoder = zstd::Encoder::new(File::create(&link_archive)?, 1)?;
879 let mut archive = tar::Builder::new(encoder);
880 let mut header = tar::Header::new_gnu();
881 header.set_entry_type(tar::EntryType::Symlink);
882 header.set_size(0);
883 header.set_mode(0o777);
884 header.set_link_name("outside")?;
885 header.set_cksum();
886 archive.append_data(&mut header, "link", std::io::empty())?;
887 archive.into_inner()?.finish()?;
888 }
889 let destination = temporary.path().join("link-output");
890 fs::create_dir(&destination)?;
891 assert!(
892 extract_archive(&link_archive, &destination)
893 .unwrap_err()
894 .to_string()
895 .contains("not a regular file or directory")
896 );
897
898 let duplicate_archive = temporary.path().join("duplicate.tar.zst");
899 {
900 let encoder = zstd::Encoder::new(File::create(&duplicate_archive)?, 1)?;
901 let mut archive = tar::Builder::new(encoder);
902 for payload in [b"first".as_slice(), b"second".as_slice()] {
903 let mut header = tar::Header::new_gnu();
904 header.set_size(payload.len() as u64);
905 header.set_mode(0o644);
906 header.set_cksum();
907 archive.append_data(&mut header, "duplicate", payload)?;
908 }
909 archive.into_inner()?.finish()?;
910 }
911 let destination = temporary.path().join("duplicate-output");
912 fs::create_dir(&destination)?;
913 assert!(
914 extract_archive(&duplicate_archive, &destination)
915 .unwrap_err()
916 .to_string()
917 .contains("duplicate path")
918 );
919 Ok(())
920 }
921}