Skip to main content

pulith_store/
lib.rs

1//! Composable local artifact storage for Pulith.
2
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use pulith_archive::entry::ArchiveReport;
7use pulith_fetch::{FetchReceipt, FetchSource};
8use pulith_fs::{
9    DEFAULT_COPY_ONLY_THRESHOLD_BYTES, FallBack, HardlinkOrCopyOptions, Workspace, atomic_write,
10    copy_dir_all,
11};
12use pulith_resource::{Metadata, ResolvedResource, ResolvedVersion, ResourceId, ValidDigest};
13use pulith_serde_backend::{JsonTextCodec, decode_slice, encode_pretty_vec};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub type Result<T> = std::result::Result<T, StoreError>;
18pub const STORE_METADATA_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone)]
21pub struct ArtifactRegistration {
22    pub source: PathBuf,
23    pub provenance: Option<StoreProvenance>,
24}
25
26pub trait IntoArtifactRegistration {
27    fn into_artifact_registration(self) -> ArtifactRegistration;
28}
29
30impl IntoArtifactRegistration for PathBuf {
31    fn into_artifact_registration(self) -> ArtifactRegistration {
32        ArtifactRegistration {
33            source: self,
34            provenance: None,
35        }
36    }
37}
38
39impl IntoArtifactRegistration for &Path {
40    fn into_artifact_registration(self) -> ArtifactRegistration {
41        ArtifactRegistration {
42            source: self.to_path_buf(),
43            provenance: None,
44        }
45    }
46}
47
48impl IntoArtifactRegistration for (&Path, StoreProvenance) {
49    fn into_artifact_registration(self) -> ArtifactRegistration {
50        ArtifactRegistration {
51            source: self.0.to_path_buf(),
52            provenance: Some(self.1),
53        }
54    }
55}
56
57impl IntoArtifactRegistration for (&Path, Option<StoreProvenance>) {
58    fn into_artifact_registration(self) -> ArtifactRegistration {
59        ArtifactRegistration {
60            source: self.0.to_path_buf(),
61            provenance: self.1,
62        }
63    }
64}
65
66impl IntoArtifactRegistration for &FetchReceipt {
67    fn into_artifact_registration(self) -> ArtifactRegistration {
68        ArtifactRegistration {
69            source: self.destination.clone(),
70            provenance: Some(StoreProvenance::from_fetch_receipt(self)),
71        }
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct ExtractRegistration {
77    pub source_dir: PathBuf,
78    pub provenance: Option<StoreProvenance>,
79}
80
81pub trait IntoExtractRegistration {
82    fn into_extract_registration(self) -> ExtractRegistration;
83}
84
85impl IntoExtractRegistration for PathBuf {
86    fn into_extract_registration(self) -> ExtractRegistration {
87        ExtractRegistration {
88            source_dir: self,
89            provenance: None,
90        }
91    }
92}
93
94impl IntoExtractRegistration for &Path {
95    fn into_extract_registration(self) -> ExtractRegistration {
96        ExtractRegistration {
97            source_dir: self.to_path_buf(),
98            provenance: None,
99        }
100    }
101}
102
103impl IntoExtractRegistration for (&Path, StoreProvenance) {
104    fn into_extract_registration(self) -> ExtractRegistration {
105        ExtractRegistration {
106            source_dir: self.0.to_path_buf(),
107            provenance: Some(self.1),
108        }
109    }
110}
111
112impl IntoExtractRegistration for (&Path, Option<StoreProvenance>) {
113    fn into_extract_registration(self) -> ExtractRegistration {
114        ExtractRegistration {
115            source_dir: self.0.to_path_buf(),
116            provenance: self.1,
117        }
118    }
119}
120
121impl IntoExtractRegistration for (&Path, &ArchiveReport) {
122    fn into_extract_registration(self) -> ExtractRegistration {
123        ExtractRegistration {
124            source_dir: self.0.to_path_buf(),
125            provenance: Some(StoreProvenance::from_archive_report(self.1)),
126        }
127    }
128}
129
130impl IntoExtractRegistration for (&FetchReceipt, &Path, &ArchiveReport) {
131    fn into_extract_registration(self) -> ExtractRegistration {
132        ExtractRegistration {
133            source_dir: self.1.to_path_buf(),
134            provenance: Some(StoreProvenance::from_fetched_archive_extraction(
135                self.0, self.2,
136            )),
137        }
138    }
139}
140
141#[derive(Debug, Error)]
142pub enum StoreError {
143    #[error(transparent)]
144    Io(#[from] std::io::Error),
145    #[error(transparent)]
146    Fs(#[from] pulith_fs::Error),
147    #[error("store root is missing: {0}")]
148    MissingRoot(&'static str),
149    #[error("logical key must not be empty")]
150    EmptyLogicalKey,
151    #[error("file name is missing from source path {0}")]
152    MissingFileName(PathBuf),
153    #[error("invalid metadata file name for key {0}")]
154    InvalidMetadataFileName(String),
155    #[error("unsupported store metadata schema version: expected {expected}, got {actual}")]
156    UnsupportedMetadataSchemaVersion { expected: u32, actual: u32 },
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct StoreRoots {
161    pub artifacts: PathBuf,
162    pub extracts: PathBuf,
163    pub metadata: PathBuf,
164}
165
166impl StoreRoots {
167    pub fn new(artifacts: PathBuf, extracts: PathBuf, metadata: PathBuf) -> Self {
168        Self {
169            artifacts,
170            extracts,
171            metadata,
172        }
173    }
174}
175
176#[derive(Debug, Clone)]
177pub struct StoreReady {
178    roots: StoreRoots,
179}
180
181impl StoreReady {
182    pub fn initialize(roots: StoreRoots) -> Result<Self> {
183        std::fs::create_dir_all(&roots.artifacts)?;
184        std::fs::create_dir_all(&roots.extracts)?;
185        std::fs::create_dir_all(&roots.metadata)?;
186        Ok(Self { roots })
187    }
188
189    pub fn roots(&self) -> &StoreRoots {
190        &self.roots
191    }
192
193    pub fn has_artifact(&self, key: &StoreKey) -> bool {
194        self.artifact_path(key).exists()
195    }
196
197    pub fn has_extract(&self, key: &StoreKey) -> bool {
198        self.extract_path(key).exists()
199    }
200
201    pub fn has_metadata(&self, key: &StoreKey) -> bool {
202        self.metadata_path(key).exists()
203    }
204
205    pub fn artifact_path(&self, key: &StoreKey) -> PathBuf {
206        self.roots.artifacts.join(key.relative_name())
207    }
208
209    pub fn extract_path(&self, key: &StoreKey) -> PathBuf {
210        self.roots.extracts.join(key.relative_name())
211    }
212
213    pub fn metadata_path(&self, key: &StoreKey) -> PathBuf {
214        self.roots
215            .metadata
216            .join(format!("{}.json", key.relative_name()))
217    }
218
219    pub fn get_artifact(&self, key: &StoreKey) -> Option<StoredArtifact> {
220        self.lookup_stored(key, StoredKind::Artifact)
221            .map(|artifact| StoredArtifact {
222                key: artifact.key,
223                path: artifact.path,
224                provenance: artifact.provenance,
225            })
226    }
227
228    pub fn get_extract(&self, key: &StoreKey) -> Option<ExtractedArtifact> {
229        self.lookup_stored(key, StoredKind::Extract)
230            .map(|extract| ExtractedArtifact {
231                key: extract.key,
232                path: extract.path,
233                provenance: extract.provenance,
234            })
235    }
236
237    pub fn get_artifact_for<K: KeyDerivation>(
238        &self,
239        resource: &ResolvedResource,
240        derivation: &K,
241    ) -> Option<StoredArtifact> {
242        derivation
243            .derive(resource)
244            .as_ref()
245            .and_then(|key| self.get_artifact(key))
246    }
247
248    pub fn get_extract_for<K: KeyDerivation>(
249        &self,
250        resource: &ResolvedResource,
251        derivation: &K,
252    ) -> Option<ExtractedArtifact> {
253        derivation
254            .derive(resource)
255            .as_ref()
256            .and_then(|key| self.get_extract(key))
257    }
258
259    pub fn get_metadata(&self, key: &StoreKey) -> Result<Option<StoreMetadataRecord>> {
260        self.load_metadata_record(key)
261    }
262
263    pub fn get_metadata_for<K: KeyDerivation>(
264        &self,
265        resource: &ResolvedResource,
266        derivation: &K,
267    ) -> Result<Option<StoreMetadataRecord>> {
268        derivation
269            .derive(resource)
270            .as_ref()
271            .map_or(Ok(None), |key| self.get_metadata(key))
272    }
273
274    pub fn list_metadata(&self) -> Result<Vec<StoreMetadataRecord>> {
275        let mut records = Vec::new();
276        for entry in std::fs::read_dir(&self.roots.metadata)? {
277            let entry = entry?;
278            if !entry.file_type()?.is_file() {
279                continue;
280            }
281            let record = Self::decode_metadata_file(&entry.path())?;
282            records.push(record);
283        }
284        Ok(records)
285    }
286
287    pub fn list_orphaned_metadata(&self) -> Result<Vec<StoreMetadataRecord>> {
288        Ok(self
289            .list_metadata()?
290            .into_iter()
291            .filter(|record| !self.record_target_exists(record))
292            .collect())
293    }
294
295    pub fn get_orphaned_metadata_for<K: KeyDerivation>(
296        &self,
297        resource: &ResolvedResource,
298        derivation: &K,
299    ) -> Result<Option<StoreMetadataRecord>> {
300        let Some(key) = derivation.derive(resource) else {
301            return Ok(None);
302        };
303
304        let Some(record) = self.get_metadata(&key)? else {
305            return Ok(None);
306        };
307
308        Ok((!self.record_target_exists(&record)).then_some(record))
309    }
310
311    pub fn plan_metadata_prune(&self, protected_keys: &[StoreKey]) -> Result<MetadataPrunePlan> {
312        let mut plan = MetadataPrunePlan::default();
313
314        for record in self.list_orphaned_metadata()? {
315            if protected_keys.contains(&record.key) {
316                plan.protected.push(record);
317            } else {
318                plan.removable.push(record);
319            }
320        }
321
322        Ok(plan)
323    }
324
325    pub fn prune_missing(&self) -> Result<PruneReport> {
326        self.prune_missing_with_protection(&[])
327    }
328
329    pub fn prune_missing_with_protection(
330        &self,
331        protected_keys: &[StoreKey],
332    ) -> Result<PruneReport> {
333        let mut report = PruneReport::default();
334        let plan = self.plan_metadata_prune(protected_keys)?;
335        report.protected_metadata = plan.protected.len();
336
337        for record in plan.removable {
338            let metadata_path = self.metadata_path(&record.key);
339            if metadata_path.exists() {
340                std::fs::remove_file(&metadata_path)?;
341                report.removed_metadata += 1;
342            }
343        }
344        Ok(report)
345    }
346
347    pub fn put_artifact_bytes(&self, key: &StoreKey, bytes: &[u8]) -> Result<StoredArtifact> {
348        let path = self.artifact_path(key);
349        atomic_write(&path, bytes, Default::default())?;
350        let artifact = StoredArtifact {
351            key: key.clone(),
352            path,
353            provenance: None,
354        };
355        self.persist_provenance(
356            &artifact.key,
357            StoredKind::Artifact,
358            artifact.provenance.as_ref(),
359        )?;
360        Ok(artifact)
361    }
362
363    pub fn import_artifact(
364        &self,
365        key: &StoreKey,
366        source: impl AsRef<Path>,
367    ) -> Result<StoredArtifact> {
368        self.import_artifact_with_provenance(key, source, None)
369    }
370
371    pub fn import_artifact_with_provenance(
372        &self,
373        key: &StoreKey,
374        source: impl AsRef<Path>,
375        provenance: Option<StoreProvenance>,
376    ) -> Result<StoredArtifact> {
377        let source = source.as_ref();
378        let file_name = source
379            .file_name()
380            .ok_or_else(|| StoreError::MissingFileName(source.to_path_buf()))?;
381        let artifact_root = self.artifact_path(key);
382        if artifact_root.exists() {
383            std::fs::remove_dir_all(&artifact_root)?;
384        }
385        let workspace_root = tempfile::tempdir()?;
386        let workspace = Workspace::new(
387            workspace_root.path().join("artifact"),
388            artifact_root.clone(),
389        )?;
390        stage_artifact_file(&workspace, source, PathBuf::from(file_name))?;
391        workspace.commit()?;
392
393        let artifact = StoredArtifact {
394            key: key.clone(),
395            path: self.artifact_path(key).join(file_name),
396            provenance,
397        };
398        self.persist_provenance(
399            &artifact.key,
400            StoredKind::Artifact,
401            artifact.provenance.as_ref(),
402        )?;
403        Ok(artifact)
404    }
405
406    pub fn register_artifact(
407        &self,
408        key: &StoreKey,
409        registration: impl IntoArtifactRegistration,
410    ) -> Result<StoredArtifact> {
411        let registration = registration.into_artifact_registration();
412        self.import_artifact_with_provenance(key, registration.source, registration.provenance)
413    }
414
415    pub fn register_extract_dir(
416        &self,
417        key: &StoreKey,
418        source_dir: impl AsRef<Path>,
419    ) -> Result<ExtractedArtifact> {
420        self.register_extract_dir_with_provenance(key, source_dir, None)
421    }
422
423    pub fn register_extract_dir_with_provenance(
424        &self,
425        key: &StoreKey,
426        source_dir: impl AsRef<Path>,
427        provenance: Option<StoreProvenance>,
428    ) -> Result<ExtractedArtifact> {
429        let source_dir = source_dir.as_ref();
430        let target = self.extract_path(key);
431
432        if target.exists() {
433            std::fs::remove_dir_all(&target)?;
434        }
435
436        copy_dir_all(source_dir, &target)?;
437        let artifact = ExtractedArtifact {
438            key: key.clone(),
439            path: target,
440            provenance,
441        };
442        self.persist_provenance(
443            &artifact.key,
444            StoredKind::Extract,
445            artifact.provenance.as_ref(),
446        )?;
447        Ok(artifact)
448    }
449
450    pub fn register_extract(
451        &self,
452        key: &StoreKey,
453        registration: impl IntoExtractRegistration,
454    ) -> Result<ExtractedArtifact> {
455        let registration = registration.into_extract_registration();
456        self.register_extract_dir_with_provenance(
457            key,
458            registration.source_dir,
459            registration.provenance,
460        )
461    }
462
463    fn persist_provenance(
464        &self,
465        key: &StoreKey,
466        kind: StoredKind,
467        provenance: Option<&StoreProvenance>,
468    ) -> Result<()> {
469        let record = StoreMetadataRecord {
470            schema_version: STORE_METADATA_SCHEMA_VERSION,
471            key: key.clone(),
472            kind,
473            provenance: provenance.cloned(),
474            updated_at_unix: now_unix(),
475        };
476        let bytes = encode_pretty_vec(&JsonTextCodec, &record).map_err(|error| {
477            StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
478        })?;
479        atomic_write(self.metadata_path(key), &bytes, Default::default())?;
480        Ok(())
481    }
482
483    fn load_provenance(&self, key: &StoreKey) -> Result<Option<StoreProvenance>> {
484        Ok(self
485            .load_metadata_record(key)?
486            .and_then(|record| record.provenance))
487    }
488
489    fn load_metadata_record(&self, key: &StoreKey) -> Result<Option<StoreMetadataRecord>> {
490        let path = self.metadata_path(key);
491        if !path.exists() {
492            return Ok(None);
493        }
494        Ok(Some(Self::decode_metadata_file(&path)?))
495    }
496
497    fn decode_metadata_file(path: &Path) -> Result<StoreMetadataRecord> {
498        let bytes = std::fs::read(path)?;
499        let record: StoreMetadataRecord =
500            decode_slice(&JsonTextCodec, &bytes).map_err(|error| {
501                StoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
502            })?;
503        record.validate()?;
504        Ok(record)
505    }
506
507    fn lookup_stored(&self, key: &StoreKey, kind: StoredKind) -> Option<StoredEntry> {
508        let path = match kind {
509            StoredKind::Artifact => self.artifact_path(key),
510            StoredKind::Extract => self.extract_path(key),
511        };
512        if !path.exists() {
513            return None;
514        }
515
516        Some(StoredEntry {
517            key: key.clone(),
518            path,
519            provenance: self.load_provenance(key).ok().flatten(),
520        })
521    }
522
523    fn record_target_exists(&self, record: &StoreMetadataRecord) -> bool {
524        match record.kind {
525            StoredKind::Artifact => self.has_artifact(&record.key),
526            StoredKind::Extract => self.has_extract(&record.key),
527        }
528    }
529}
530
531fn stage_artifact_file(workspace: &Workspace, source: &Path, relative_path: PathBuf) -> Result<()> {
532    workspace.stage_file_by_size(
533        source,
534        &relative_path,
535        DEFAULT_COPY_ONLY_THRESHOLD_BYTES,
536        HardlinkOrCopyOptions::new().fallback(FallBack::Copy),
537    )?;
538    Ok(())
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542pub enum StoreKey {
543    Digest(ValidDigest),
544    NamedVersion {
545        id: ResourceId,
546        version: ResolvedVersion,
547    },
548    Logical(String),
549}
550
551impl StoreKey {
552    pub fn logical(value: impl Into<String>) -> Result<Self> {
553        let value = value.into();
554        if value.is_empty() {
555            return Err(StoreError::EmptyLogicalKey);
556        }
557        Ok(Self::Logical(value))
558    }
559
560    pub fn relative_name(&self) -> String {
561        match self {
562            Self::Digest(digest) => format!(
563                "digest-{}-{}",
564                algorithm_name(&digest.algorithm),
565                digest.hex()
566            ),
567            Self::NamedVersion { id, version } => {
568                format!(
569                    "named-{}-{}",
570                    sanitize(&id.as_string()),
571                    sanitize(version.as_str())
572                )
573            }
574            Self::Logical(value) => format!("logical-{}", sanitize(value)),
575        }
576    }
577}
578
579pub trait KeyDerivation {
580    fn derive(&self, resource: &ResolvedResource) -> Option<StoreKey>;
581}
582
583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584pub struct StoreProvenance {
585    pub origin: Option<String>,
586    pub metadata: Metadata,
587}
588
589impl StoreProvenance {
590    pub fn from_fetch_receipt(receipt: &FetchReceipt) -> Self {
591        let origin = match &receipt.source {
592            FetchSource::Url(url) => Some(url.clone()),
593            FetchSource::LocalPath(path) => Some(path.to_string_lossy().into_owned()),
594        };
595
596        let metadata = Self::fetch_metadata(receipt);
597
598        Self { origin, metadata }
599    }
600
601    pub fn from_archive_report(report: &ArchiveReport) -> Self {
602        Self {
603            origin: None,
604            metadata: Self::archive_metadata(report),
605        }
606    }
607
608    pub fn from_fetched_archive_extraction(receipt: &FetchReceipt, report: &ArchiveReport) -> Self {
609        let mut metadata = Metadata::new();
610        metadata.extend(Self::fetch_metadata(receipt));
611        metadata.extend(Self::archive_metadata(report));
612
613        Self {
614            origin: Self::from_fetch_receipt(receipt).origin,
615            metadata,
616        }
617    }
618
619    fn fetch_metadata(receipt: &FetchReceipt) -> Metadata {
620        let mut metadata = Metadata::new();
621        if let Some(sha256_hex) = &receipt.sha256_hex {
622            metadata.insert("fetch.sha256".to_string(), sha256_hex.clone());
623        }
624        metadata
625    }
626
627    fn archive_metadata(report: &ArchiveReport) -> Metadata {
628        Metadata::from([
629            ("archive.format".to_string(), format!("{:?}", report.format)),
630            (
631                "archive.entry_count".to_string(),
632                report.entry_count.to_string(),
633            ),
634            (
635                "archive.total_bytes".to_string(),
636                report.total_bytes.to_string(),
637            ),
638        ])
639    }
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
643pub enum StoredKind {
644    Artifact,
645    Extract,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct StoreMetadataRecord {
650    #[serde(default = "default_store_metadata_schema_version")]
651    pub schema_version: u32,
652    pub key: StoreKey,
653    pub kind: StoredKind,
654    pub provenance: Option<StoreProvenance>,
655    pub updated_at_unix: u64,
656}
657
658impl StoreMetadataRecord {
659    pub fn validate(&self) -> Result<()> {
660        if self.schema_version != STORE_METADATA_SCHEMA_VERSION {
661            return Err(StoreError::UnsupportedMetadataSchemaVersion {
662                expected: STORE_METADATA_SCHEMA_VERSION,
663                actual: self.schema_version,
664            });
665        }
666        Ok(())
667    }
668}
669
670fn default_store_metadata_schema_version() -> u32 {
671    STORE_METADATA_SCHEMA_VERSION
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
675pub struct PruneReport {
676    pub removed_metadata: usize,
677    pub protected_metadata: usize,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
681pub struct MetadataPrunePlan {
682    pub removable: Vec<StoreMetadataRecord>,
683    pub protected: Vec<StoreMetadataRecord>,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687pub struct StoredArtifact {
688    pub key: StoreKey,
689    pub path: PathBuf,
690    pub provenance: Option<StoreProvenance>,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694pub struct ExtractedArtifact {
695    pub key: StoreKey,
696    pub path: PathBuf,
697    pub provenance: Option<StoreProvenance>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
701struct StoredEntry {
702    key: StoreKey,
703    path: PathBuf,
704    provenance: Option<StoreProvenance>,
705}
706
707fn algorithm_name(algorithm: &pulith_resource::DigestAlgorithm) -> String {
708    match algorithm {
709        pulith_resource::DigestAlgorithm::Sha256 => "sha256".to_string(),
710        pulith_resource::DigestAlgorithm::Blake3 => "blake3".to_string(),
711        pulith_resource::DigestAlgorithm::Custom(value) => sanitize(value),
712    }
713}
714
715fn sanitize(value: &str) -> String {
716    value
717        .chars()
718        .map(|ch| {
719            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
720                ch
721            } else {
722                '-'
723            }
724        })
725        .collect()
726}
727
728fn now_unix() -> u64 {
729    SystemTime::now()
730        .duration_since(UNIX_EPOCH)
731        .unwrap_or_default()
732        .as_secs()
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use pulith_archive::{ArchiveFormat, ArchiveReport};
739    use pulith_fetch::{FetchReceipt, FetchSource};
740    use pulith_resource::{
741        RequestedResource, ResolvedLocator, ResourceLocator, ResourceSpec, ValidUrl,
742    };
743    use pulith_serde_backend::CompactJsonTextCodec;
744
745    #[test]
746    fn store_provenance_from_fetch_receipt_translates_source_and_digest() {
747        let receipt = FetchReceipt {
748            source: FetchSource::Url("https://example.com/runtime.zip".to_string()),
749            destination: PathBuf::from("/tmp/runtime.zip"),
750            bytes_downloaded: 12,
751            total_bytes: Some(12),
752            sha256_hex: Some("abc123".to_string()),
753        };
754
755        let provenance = StoreProvenance::from_fetch_receipt(&receipt);
756        assert_eq!(
757            provenance.origin.as_deref(),
758            Some("https://example.com/runtime.zip")
759        );
760        assert_eq!(
761            provenance.metadata.get("fetch.sha256").map(String::as_str),
762            Some("abc123")
763        );
764    }
765
766    #[test]
767    fn store_provenance_from_archive_report_populates_archive_metadata() {
768        let report = ArchiveReport {
769            format: ArchiveFormat::Zip,
770            entry_count: 2,
771            total_bytes: 42,
772            entries: vec![],
773        };
774
775        let provenance = StoreProvenance::from_archive_report(&report);
776        assert_eq!(
777            provenance
778                .metadata
779                .get("archive.format")
780                .map(String::as_str),
781            Some("Zip")
782        );
783        assert_eq!(
784            provenance
785                .metadata
786                .get("archive.entry_count")
787                .map(String::as_str),
788            Some("2")
789        );
790        assert_eq!(
791            provenance
792                .metadata
793                .get("archive.total_bytes")
794                .map(String::as_str),
795            Some("42")
796        );
797    }
798
799    #[test]
800    fn store_provenance_from_fetched_archive_extraction_merges_fetch_and_archive() {
801        let receipt = FetchReceipt {
802            source: FetchSource::Url("https://example.com/runtime.zip".to_string()),
803            destination: PathBuf::from("/tmp/runtime.zip"),
804            bytes_downloaded: 12,
805            total_bytes: Some(12),
806            sha256_hex: Some("abc123".to_string()),
807        };
808        let report = ArchiveReport {
809            format: ArchiveFormat::Zip,
810            entry_count: 2,
811            total_bytes: 42,
812            entries: vec![],
813        };
814
815        let provenance = StoreProvenance::from_fetched_archive_extraction(&receipt, &report);
816        assert_eq!(
817            provenance.origin.as_deref(),
818            Some("https://example.com/runtime.zip")
819        );
820        assert_eq!(
821            provenance.metadata.get("fetch.sha256").map(String::as_str),
822            Some("abc123")
823        );
824        assert_eq!(
825            provenance
826                .metadata
827                .get("archive.format")
828                .map(String::as_str),
829            Some("Zip")
830        );
831    }
832
833    #[test]
834    fn store_initializes_and_writes_artifact() {
835        let temp = tempfile::tempdir().unwrap();
836        let store = StoreReady::initialize(StoreRoots::new(
837            temp.path().join("artifacts"),
838            temp.path().join("extracts"),
839            temp.path().join("metadata"),
840        ))
841        .unwrap();
842
843        let key = StoreKey::logical("node-lts").unwrap();
844        let artifact = store.put_artifact_bytes(&key, b"hello").unwrap();
845        assert!(artifact.path.exists());
846        assert!(store.get_artifact(&key).is_some());
847    }
848
849    #[test]
850    fn named_version_key_uses_resource_identity() {
851        let key = StoreKey::NamedVersion {
852            id: ResourceId::parse("nodejs.org/node").unwrap(),
853            version: ResolvedVersion::new("20.12.1").unwrap(),
854        };
855        assert!(key.relative_name().contains("nodejs.org-node"));
856    }
857
858    #[test]
859    fn trait_can_derive_key_from_resolved_resource() {
860        struct ByVersion;
861        impl KeyDerivation for ByVersion {
862            fn derive(&self, resource: &ResolvedResource) -> Option<StoreKey> {
863                Some(StoreKey::NamedVersion {
864                    id: resource.spec().id.clone(),
865                    version: resource.version().clone(),
866                })
867            }
868        }
869
870        let requested = RequestedResource::new(ResourceSpec::new(
871            ResourceId::parse("example/runtime").unwrap(),
872            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.tar.gz").unwrap()),
873        ));
874        let resolved = requested.resolve(
875            ResolvedVersion::new("1.0.0").unwrap(),
876            ResolvedLocator::Url(
877                ValidUrl::parse("https://mirror.example.com/runtime.tar.gz").unwrap(),
878            ),
879            None,
880        );
881
882        assert!(ByVersion.derive(&resolved).is_some());
883    }
884
885    #[test]
886    fn store_can_lookup_artifact_for_resource_via_key_derivation() {
887        struct ByVersion;
888        impl KeyDerivation for ByVersion {
889            fn derive(&self, resource: &ResolvedResource) -> Option<StoreKey> {
890                Some(StoreKey::NamedVersion {
891                    id: resource.spec().id.clone(),
892                    version: resource.version().clone(),
893                })
894            }
895        }
896
897        let temp = tempfile::tempdir().unwrap();
898        let store = StoreReady::initialize(StoreRoots::new(
899            temp.path().join("artifacts"),
900            temp.path().join("extracts"),
901            temp.path().join("metadata"),
902        ))
903        .unwrap();
904        let resolved = RequestedResource::new(ResourceSpec::new(
905            ResourceId::parse("example/runtime").unwrap(),
906            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.tar.gz").unwrap()),
907        ))
908        .resolve(
909            ResolvedVersion::new("1.0.0").unwrap(),
910            ResolvedLocator::Url(
911                ValidUrl::parse("https://mirror.example.com/runtime.tar.gz").unwrap(),
912            ),
913            None,
914        );
915        let key = ByVersion.derive(&resolved).unwrap();
916
917        store.put_artifact_bytes(&key, b"hello").unwrap();
918
919        let artifact = store.get_artifact_for(&resolved, &ByVersion).unwrap();
920        assert!(artifact.path.exists());
921        assert_eq!(artifact.key, key);
922    }
923
924    #[test]
925    fn store_can_lookup_extract_metadata_for_resource_via_key_derivation() {
926        struct ByVersion;
927        impl KeyDerivation for ByVersion {
928            fn derive(&self, resource: &ResolvedResource) -> Option<StoreKey> {
929                Some(StoreKey::NamedVersion {
930                    id: resource.spec().id.clone(),
931                    version: resource.version().clone(),
932                })
933            }
934        }
935
936        let temp = tempfile::tempdir().unwrap();
937        let store = StoreReady::initialize(StoreRoots::new(
938            temp.path().join("artifacts"),
939            temp.path().join("extracts"),
940            temp.path().join("metadata"),
941        ))
942        .unwrap();
943        let resolved = RequestedResource::new(ResourceSpec::new(
944            ResourceId::parse("example/runtime").unwrap(),
945            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.tar.gz").unwrap()),
946        ))
947        .resolve(
948            ResolvedVersion::new("1.0.0").unwrap(),
949            ResolvedLocator::Url(
950                ValidUrl::parse("https://mirror.example.com/runtime.tar.gz").unwrap(),
951            ),
952            None,
953        );
954        let key = ByVersion.derive(&resolved).unwrap();
955        let extract_root = temp.path().join("extract-root");
956        std::fs::create_dir_all(&extract_root).unwrap();
957        std::fs::write(extract_root.join("tool.exe"), b"hello").unwrap();
958
959        store
960            .register_extract_dir_with_provenance(
961                &key,
962                &extract_root,
963                Some(StoreProvenance {
964                    origin: Some("integration-test".to_string()),
965                    metadata: Metadata::from([("archive.format".to_string(), "Zip".to_string())]),
966                }),
967            )
968            .unwrap();
969
970        let extract = store.get_extract_for(&resolved, &ByVersion).unwrap();
971        assert_eq!(extract.key, key);
972
973        let metadata = store
974            .get_metadata_for(&resolved, &ByVersion)
975            .unwrap()
976            .unwrap();
977        assert_eq!(metadata.kind, StoredKind::Extract);
978        assert_eq!(
979            metadata.provenance.unwrap().origin.as_deref(),
980            Some("integration-test")
981        );
982    }
983
984    #[test]
985    fn store_persists_and_reads_provenance() {
986        let temp = tempfile::tempdir().unwrap();
987        let store = StoreReady::initialize(StoreRoots::new(
988            temp.path().join("artifacts"),
989            temp.path().join("extracts"),
990            temp.path().join("metadata"),
991        ))
992        .unwrap();
993
994        let source = temp.path().join("source.bin");
995        std::fs::write(&source, b"hello").unwrap();
996        let key = StoreKey::logical("runtime").unwrap();
997        let artifact = store
998            .import_artifact_with_provenance(
999                &key,
1000                &source,
1001                Some(StoreProvenance {
1002                    origin: Some("integration-test".to_string()),
1003                    metadata: Metadata::new(),
1004                }),
1005            )
1006            .unwrap();
1007
1008        assert_eq!(
1009            artifact.provenance.as_ref().unwrap().origin.as_deref(),
1010            Some("integration-test")
1011        );
1012        let looked_up = store.get_artifact(&key).unwrap();
1013        assert_eq!(
1014            looked_up.provenance.as_ref().unwrap().origin.as_deref(),
1015            Some("integration-test")
1016        );
1017    }
1018
1019    #[test]
1020    fn register_artifact_absorbs_path_and_provenance_tuple() {
1021        let temp = tempfile::tempdir().unwrap();
1022        let store = StoreReady::initialize(StoreRoots::new(
1023            temp.path().join("artifacts"),
1024            temp.path().join("extracts"),
1025            temp.path().join("metadata"),
1026        ))
1027        .unwrap();
1028
1029        let source = temp.path().join("source.bin");
1030        std::fs::write(&source, b"hello").unwrap();
1031        let key = StoreKey::logical("runtime-register").unwrap();
1032        let artifact = store
1033            .register_artifact(
1034                &key,
1035                (
1036                    source.as_path(),
1037                    StoreProvenance {
1038                        origin: Some("fetch".to_string()),
1039                        metadata: Metadata::from([("fetch.sha256".to_string(), "abc".to_string())]),
1040                    },
1041                ),
1042            )
1043            .unwrap();
1044
1045        assert!(artifact.path.exists());
1046        assert_eq!(
1047            artifact.provenance.unwrap().origin.as_deref(),
1048            Some("fetch")
1049        );
1050    }
1051
1052    #[test]
1053    fn register_extract_absorbs_path_and_provenance_tuple() {
1054        let temp = tempfile::tempdir().unwrap();
1055        let store = StoreReady::initialize(StoreRoots::new(
1056            temp.path().join("artifacts"),
1057            temp.path().join("extracts"),
1058            temp.path().join("metadata"),
1059        ))
1060        .unwrap();
1061
1062        let extract_root = temp.path().join("extract-root");
1063        std::fs::create_dir_all(extract_root.join("bin")).unwrap();
1064        std::fs::write(extract_root.join("bin/tool"), b"hello").unwrap();
1065        let key = StoreKey::logical("runtime-extract-register").unwrap();
1066        let extract = store
1067            .register_extract(
1068                &key,
1069                (
1070                    extract_root.as_path(),
1071                    StoreProvenance {
1072                        origin: Some("archive".to_string()),
1073                        metadata: Metadata::from([(
1074                            "archive.format".to_string(),
1075                            "tar.gz".to_string(),
1076                        )]),
1077                    },
1078                ),
1079            )
1080            .unwrap();
1081
1082        assert!(extract.path.join("bin/tool").exists());
1083        assert_eq!(
1084            extract.provenance.unwrap().origin.as_deref(),
1085            Some("archive")
1086        );
1087    }
1088
1089    #[test]
1090    fn prune_missing_removes_orphaned_metadata() {
1091        let temp = tempfile::tempdir().unwrap();
1092        let store = StoreReady::initialize(StoreRoots::new(
1093            temp.path().join("artifacts"),
1094            temp.path().join("extracts"),
1095            temp.path().join("metadata"),
1096        ))
1097        .unwrap();
1098
1099        let key = StoreKey::logical("orphan").unwrap();
1100        store.put_artifact_bytes(&key, b"hello").unwrap();
1101        std::fs::remove_file(store.artifact_path(&key)).unwrap();
1102
1103        let report = store.prune_missing().unwrap();
1104        assert_eq!(report.removed_metadata, 1);
1105        assert!(store.list_metadata().unwrap().is_empty());
1106    }
1107
1108    #[test]
1109    fn store_can_list_orphaned_metadata_before_pruning() {
1110        let temp = tempfile::tempdir().unwrap();
1111        let store = StoreReady::initialize(StoreRoots::new(
1112            temp.path().join("artifacts"),
1113            temp.path().join("extracts"),
1114            temp.path().join("metadata"),
1115        ))
1116        .unwrap();
1117
1118        let artifact_key = StoreKey::logical("artifact-orphan").unwrap();
1119        store.put_artifact_bytes(&artifact_key, b"hello").unwrap();
1120        std::fs::remove_file(store.artifact_path(&artifact_key)).unwrap();
1121
1122        let extract_key = StoreKey::logical("extract-orphan").unwrap();
1123        let extract_root = temp.path().join("extract-root");
1124        std::fs::create_dir_all(&extract_root).unwrap();
1125        std::fs::write(extract_root.join("tool.exe"), b"hello").unwrap();
1126        store
1127            .register_extract_dir(&extract_key, &extract_root)
1128            .unwrap();
1129        std::fs::remove_dir_all(store.extract_path(&extract_key)).unwrap();
1130
1131        let orphans = store.list_orphaned_metadata().unwrap();
1132        assert_eq!(orphans.len(), 2);
1133        assert!(orphans.iter().any(|record| record.key == artifact_key));
1134        assert!(orphans.iter().any(|record| record.key == extract_key));
1135    }
1136
1137    #[test]
1138    fn store_can_lookup_orphaned_metadata_for_resource_via_key_derivation() {
1139        struct ByVersion;
1140        impl KeyDerivation for ByVersion {
1141            fn derive(&self, resource: &ResolvedResource) -> Option<StoreKey> {
1142                Some(StoreKey::NamedVersion {
1143                    id: resource.spec().id.clone(),
1144                    version: resource.version().clone(),
1145                })
1146            }
1147        }
1148
1149        let temp = tempfile::tempdir().unwrap();
1150        let store = StoreReady::initialize(StoreRoots::new(
1151            temp.path().join("artifacts"),
1152            temp.path().join("extracts"),
1153            temp.path().join("metadata"),
1154        ))
1155        .unwrap();
1156        let resolved = RequestedResource::new(ResourceSpec::new(
1157            ResourceId::parse("example/runtime").unwrap(),
1158            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.tar.gz").unwrap()),
1159        ))
1160        .resolve(
1161            ResolvedVersion::new("1.0.0").unwrap(),
1162            ResolvedLocator::Url(
1163                ValidUrl::parse("https://mirror.example.com/runtime.tar.gz").unwrap(),
1164            ),
1165            None,
1166        );
1167        let key = ByVersion.derive(&resolved).unwrap();
1168
1169        store.put_artifact_bytes(&key, b"hello").unwrap();
1170        std::fs::remove_file(store.artifact_path(&key)).unwrap();
1171
1172        let orphan = store
1173            .get_orphaned_metadata_for(&resolved, &ByVersion)
1174            .unwrap()
1175            .unwrap();
1176        assert_eq!(orphan.key, key);
1177        assert_eq!(orphan.kind, StoredKind::Artifact);
1178    }
1179
1180    #[test]
1181    fn store_can_plan_protected_metadata_prune() {
1182        let temp = tempfile::tempdir().unwrap();
1183        let store = StoreReady::initialize(StoreRoots::new(
1184            temp.path().join("artifacts"),
1185            temp.path().join("extracts"),
1186            temp.path().join("metadata"),
1187        ))
1188        .unwrap();
1189
1190        let protected_key = StoreKey::logical("protected-orphan").unwrap();
1191        store.put_artifact_bytes(&protected_key, b"hello").unwrap();
1192        std::fs::remove_file(store.artifact_path(&protected_key)).unwrap();
1193
1194        let removable_key = StoreKey::logical("removable-orphan").unwrap();
1195        store.put_artifact_bytes(&removable_key, b"hello").unwrap();
1196        std::fs::remove_file(store.artifact_path(&removable_key)).unwrap();
1197
1198        let plan = store
1199            .plan_metadata_prune(std::slice::from_ref(&protected_key))
1200            .unwrap();
1201        assert_eq!(plan.protected.len(), 1);
1202        assert_eq!(plan.protected[0].key, protected_key);
1203        assert_eq!(plan.removable.len(), 1);
1204        assert_eq!(plan.removable[0].key, removable_key);
1205    }
1206
1207    #[test]
1208    fn store_prune_can_skip_protected_metadata() {
1209        let temp = tempfile::tempdir().unwrap();
1210        let store = StoreReady::initialize(StoreRoots::new(
1211            temp.path().join("artifacts"),
1212            temp.path().join("extracts"),
1213            temp.path().join("metadata"),
1214        ))
1215        .unwrap();
1216
1217        let protected_key = StoreKey::logical("protected-orphan").unwrap();
1218        store.put_artifact_bytes(&protected_key, b"hello").unwrap();
1219        std::fs::remove_file(store.artifact_path(&protected_key)).unwrap();
1220
1221        let report = store
1222            .prune_missing_with_protection(std::slice::from_ref(&protected_key))
1223            .unwrap();
1224        assert_eq!(report.removed_metadata, 0);
1225        assert_eq!(report.protected_metadata, 1);
1226        assert!(store.has_metadata(&protected_key));
1227    }
1228
1229    #[test]
1230    fn store_rejects_unsupported_metadata_schema_version() {
1231        let temp = tempfile::tempdir().unwrap();
1232        let store = StoreReady::initialize(StoreRoots::new(
1233            temp.path().join("artifacts"),
1234            temp.path().join("extracts"),
1235            temp.path().join("metadata"),
1236        ))
1237        .unwrap();
1238
1239        let key = StoreKey::logical("invalid-schema").unwrap();
1240        let path = store.metadata_path(&key);
1241        let invalid = StoreMetadataRecord {
1242            schema_version: STORE_METADATA_SCHEMA_VERSION + 1,
1243            key,
1244            kind: StoredKind::Artifact,
1245            provenance: None,
1246            updated_at_unix: 0,
1247        };
1248        let bytes = encode_pretty_vec(&JsonTextCodec, &invalid).unwrap();
1249        atomic_write(path, &bytes, Default::default()).unwrap();
1250
1251        assert!(matches!(
1252            store.list_metadata(),
1253            Err(StoreError::UnsupportedMetadataSchemaVersion {
1254                expected,
1255                actual
1256            }) if expected == STORE_METADATA_SCHEMA_VERSION && actual == STORE_METADATA_SCHEMA_VERSION + 1
1257        ));
1258    }
1259
1260    #[test]
1261    fn store_list_metadata_accepts_compact_json_payload() {
1262        let temp = tempfile::tempdir().unwrap();
1263        let store = StoreReady::initialize(StoreRoots::new(
1264            temp.path().join("artifacts"),
1265            temp.path().join("extracts"),
1266            temp.path().join("metadata"),
1267        ))
1268        .unwrap();
1269
1270        let key = StoreKey::logical("compact-json").unwrap();
1271        let path = store.metadata_path(&key);
1272        let record = StoreMetadataRecord {
1273            schema_version: STORE_METADATA_SCHEMA_VERSION,
1274            key: key.clone(),
1275            kind: StoredKind::Artifact,
1276            provenance: None,
1277            updated_at_unix: 1,
1278        };
1279        let bytes = encode_pretty_vec(&CompactJsonTextCodec, &record).unwrap();
1280        atomic_write(path, &bytes, Default::default()).unwrap();
1281
1282        let listed = store.list_metadata().unwrap();
1283        assert_eq!(listed.len(), 1);
1284        assert_eq!(listed[0].key, key);
1285    }
1286}