Skip to main content

fpm_rs/datasets/
registry.rs

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
18/// Registry document version understood by this release.
19pub const DATASET_REGISTRY_VERSION: u32 = 1;
20/// Default registry source used when no API or environment override is set.
21pub const DEFAULT_DATASET_REGISTRY_URL: &str =
22    "https://raw.githubusercontent.com/hgrecco/fpm-rs/main/dataset_registry.json";
23/// Environment variable overriding [`DEFAULT_DATASET_REGISTRY_URL`].
24pub const DATASET_REGISTRY_URL_ENV: &str = "FPM_RS_DATASET_REGISTRY_URL";
25/// Environment variable overriding the platform dataset cache directory.
26pub 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/// Immutable compressed bundle information from a registry entry.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct DatasetArchive {
39    /// HTTPS or file URL for the immutable compressed tar archive.
40    pub url: String,
41    /// Lowercase hexadecimal SHA-256 digest of the archive bytes.
42    pub sha256: String,
43    /// Exact compressed archive size in bytes.
44    pub size_bytes: u64,
45}
46
47/// License metadata from a registry entry.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct DatasetLicense {
51    /// SPDX license identifier for the distributed dataset.
52    pub spdx: String,
53    /// Authoritative license text or record URL.
54    pub url: String,
55}
56
57/// Preferred citation metadata from a registry entry.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct DatasetCitation {
61    /// Publication DOI identifier recorded by the registry contract.
62    pub doi: String,
63    /// Complete human-readable bibliographic citation.
64    pub text: String,
65}
66
67/// Original-source provenance from a registry entry.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct DatasetSource {
71    /// Authoritative original dataset or project URL.
72    pub url: String,
73    /// Description of source acquisition and external conversion provenance.
74    pub description: String,
75}
76
77/// One current immutable dataset version advertised by a registry.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct DatasetRegistryEntry {
81    /// Stable path-safe dataset identifier.
82    pub id: String,
83    /// Immutable path-safe dataset version.
84    pub version: String,
85    /// Short human-readable dataset title.
86    pub title: String,
87    /// Human-readable scientific and acquisition summary.
88    pub description: String,
89    /// Dataset bundle format version expected after extraction.
90    pub format_version: u32,
91    /// Immutable download location, digest, and byte size.
92    pub archive: DatasetArchive,
93    /// License identifier and authoritative URL.
94    pub license: DatasetLicense,
95    /// Preferred publication citation metadata.
96    pub citation: DatasetCitation,
97    /// Original-source provenance.
98    pub source: DatasetSource,
99    /// Search and filtering tags.
100    pub tags: Vec<String>,
101}
102
103impl DatasetRegistryEntry {
104    /// Validates all version-1 entry invariants.
105    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/// Strict versioned dataset registry document.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct DatasetRegistryDocument {
181    /// Registry schema version; must equal [`DATASET_REGISTRY_VERSION`].
182    pub registry_version: u32,
183    /// Current immutable dataset versions, with unique [`DatasetRegistryEntry::id`] values.
184    pub datasets: Vec<DatasetRegistryEntry>,
185}
186
187impl DatasetRegistryDocument {
188    /// Parses and validates a registry JSON document.
189    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    /// Validates the registry version, entry metadata, and ID uniqueness.
196    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/// A registry entry annotated with its current managed-cache status.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub struct DatasetListing {
227    /// Validated registry metadata.
228    pub entry: DatasetRegistryEntry,
229    /// Whether the current version is present and valid in the managed cache.
230    pub cached: bool,
231    /// Installed version directory when `cached` is true.
232    pub cache_path: Option<PathBuf>,
233}
234
235/// Registry client with verified downloads and a managed local cache.
236#[derive(Clone, Debug)]
237pub struct DatasetRegistry {
238    registry_url: String,
239    cache_dir: PathBuf,
240}
241
242impl DatasetRegistry {
243    /// Resolves the registry source and cache directory from environment and
244    /// platform defaults.
245    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    /// Creates a registry using explicit source and cache values.
263    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", &registry_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    /// Returns the configured registry URL, file URL, or filesystem path.
279    pub fn registry_url(&self) -> &str {
280        &self.registry_url
281    }
282
283    /// Returns the root of the managed cache.
284    pub fn cache_dir(&self) -> &Path {
285        &self.cache_dir
286    }
287
288    /// Fetches the registry and reports whether each current version is cached.
289    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    /// Downloads, verifies, validates, and caches the current version of `id`.
308    ///
309    /// An already-valid current installation is returned unchanged.
310    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    /// Downloads every registry entry sequentially and stops on the first error.
318    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    /// Opens `id`, downloading it first when necessary.
328    ///
329    /// A corrupt current installation is removed and installed once more.
330    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    /// Removes every cached version of `id`.
360    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    /// Removes the complete marked managed cache and returns the version count.
377    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
599/// Opens a registry dataset using environment and platform defaults.
600pub 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}