1use 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}