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