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    pub url: String,
40    pub sha256: String,
41    pub size_bytes: u64,
42}
43
44/// License metadata from a registry entry.
45#[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/// Preferred citation metadata from a registry entry.
53#[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/// Original-source provenance from a registry entry.
61#[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/// One current immutable dataset version advertised by a registry.
69#[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    /// Validates all version-1 entry invariants.
86    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/// Strict versioned dataset registry document.
159#[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    /// Parses and validates a registry JSON document.
168    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    /// Validates the registry version, entry metadata, and ID uniqueness.
175    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/// A registry entry annotated with its current managed-cache status.
204#[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/// Registry client with verified downloads and a managed local cache.
212#[derive(Clone, Debug)]
213pub struct DatasetRegistry {
214    registry_url: String,
215    cache_dir: PathBuf,
216}
217
218impl DatasetRegistry {
219    /// Resolves the registry source and cache directory from environment and
220    /// platform defaults.
221    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    /// Creates a registry using explicit source and cache values.
239    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", &registry_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    /// Returns the configured registry URL, file URL, or filesystem path.
255    pub fn registry_url(&self) -> &str {
256        &self.registry_url
257    }
258
259    /// Returns the root of the managed cache.
260    pub fn cache_dir(&self) -> &Path {
261        &self.cache_dir
262    }
263
264    /// Fetches the registry and reports whether each current version is cached.
265    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    /// Downloads, verifies, validates, and caches the current version of `id`.
284    ///
285    /// An already-valid current installation is returned unchanged.
286    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    /// Downloads every registry entry sequentially and stops on the first error.
294    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    /// Opens `id`, downloading it first when necessary.
304    ///
305    /// A corrupt current installation is removed and installed once more.
306    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    /// Removes every cached version of `id`.
336    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    /// Removes the complete marked managed cache and returns the version count.
353    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
575/// Opens a registry dataset using environment and platform defaults.
576pub 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}