Skip to main content

microsandbox_image/cache/
store.rs

1//! Global on-disk image and layer cache.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use oci_client::Reference;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest as Sha2Digest, Sha256};
9
10use crate::{
11    config::ImageConfig,
12    digest::Digest,
13    erofs::ErofsReader,
14    error::{ImageError, ImageResult},
15};
16
17//--------------------------------------------------------------------------------------------------
18// Constants
19//--------------------------------------------------------------------------------------------------
20
21/// Subdirectory for per-layer EROFS images (keyed by diff_id).
22const LAYERS_DIR: &str = "layers";
23
24/// Subdirectory for fsmeta EROFS images (keyed by manifest digest).
25const FSMETA_DIR: &str = "fsmeta";
26
27/// Subdirectory for VMDK descriptors (keyed by manifest digest).
28const VMDK_DIR: &str = "vmdk";
29
30/// Root directory for reusable flat ext4 artifacts.
31const FLAT_DIR: &str = "flat";
32const FLAT_REFS_DIR: &str = "refs";
33const FLAT_BLOBS_DIR: &str = "blobs";
34const FLAT_LOCKS_DIR: &str = "locks";
35
36/// Subdirectory for cached manifest + config metadata.
37const MANIFESTS_DIR: &str = "manifests";
38
39/// Subdirectory for transient staging (downloads, work dirs).
40const TMP_DIR: &str = "tmp";
41
42/// EROFS images are emitted in 4 KiB filesystem blocks.
43const EROFS_ALIGNMENT_BYTES: u64 = 4096;
44
45//--------------------------------------------------------------------------------------------------
46// Types
47//--------------------------------------------------------------------------------------------------
48
49/// On-disk global cache for OCI layers and EROFS images.
50///
51/// Layout:
52/// ```text
53/// ~/.microsandbox/cache/manifests/<sha256-of-ref>.json       # manifest + config metadata
54/// ~/.microsandbox/cache/tmp/<blob>.part                      # partial downloads
55/// ~/.microsandbox/cache/tmp/<blob>.download.lock             # download flock files
56/// ~/.microsandbox/cache/tmp/<blob>.work/                     # materialization work dirs
57/// ~/.microsandbox/cache/layers/<diff_id_safe>.erofs          # per-layer EROFS
58/// ~/.microsandbox/cache/layers/<diff_id_safe>.erofs.lock     # materialization flock
59/// ~/.microsandbox/cache/fsmeta/<manifest_safe>.erofs         # fsmeta EROFS (fsmerge metadata)
60/// ~/.microsandbox/cache/fsmeta/<manifest_safe>.erofs.lock    # materialization flock
61/// ~/.microsandbox/cache/vmdk/<manifest_safe>.vmdk            # VMDK descriptor
62/// ~/.microsandbox/cache/vmdk/<manifest_safe>.vmdk.lock       # materialization flock
63/// ```
64#[derive(Clone)]
65pub struct GlobalCache {
66    /// Root of the layer EROFS cache (`~/.microsandbox/cache/layers/`).
67    layers_dir: PathBuf,
68
69    /// Root of the fsmeta EROFS cache (`~/.microsandbox/cache/fsmeta/`).
70    fsmeta_dir: PathBuf,
71
72    /// Root of the VMDK descriptor cache (`~/.microsandbox/cache/vmdk/`).
73    vmdk_dir: PathBuf,
74
75    /// Manifest-keyed references to immutable flat artifacts.
76    flat_refs_dir: PathBuf,
77
78    /// Content-addressed immutable raw ext4 artifacts.
79    flat_blobs_dir: PathBuf,
80
81    /// Per-derivation materialization locks.
82    flat_locks_dir: PathBuf,
83
84    /// Root of the manifest metadata cache (`~/.microsandbox/cache/manifests/`).
85    manifests_dir: PathBuf,
86
87    /// Root of the transient staging area (`~/.microsandbox/cache/tmp/`).
88    tmp_dir: PathBuf,
89}
90
91/// Cached metadata for a pulled image reference.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct CachedImageMetadata {
94    /// Content-addressable digest of the resolved manifest.
95    pub manifest_digest: String,
96    /// Content-addressable digest of the config blob.
97    pub config_digest: String,
98    /// Raw resolved image manifest JSON.
99    pub raw_manifest_json: String,
100    /// Raw image config JSON.
101    pub raw_config_json: String,
102    /// Parsed OCI image configuration.
103    pub config: ImageConfig,
104    /// Layer metadata in bottom-to-top order.
105    pub layers: Vec<CachedLayerMetadata>,
106}
107
108/// Cached metadata for a single layer descriptor.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CachedLayerMetadata {
111    /// Compressed layer digest from the manifest (blob digest).
112    pub digest: String,
113    /// OCI media type of the layer blob.
114    pub media_type: Option<String>,
115    /// Compressed blob size in bytes.
116    pub size_bytes: Option<u64>,
117    /// Uncompressed diff ID from the image config.
118    pub diff_id: String,
119}
120
121/// Manifest-keyed reference to one validated immutable flat rootfs artifact.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct FlatRootfsRef {
124    /// Reference schema version.
125    pub schema: u32,
126    /// Resolved OCI manifest digest used as the requested input.
127    pub manifest_digest: String,
128    /// Complete derivation digest including platform and materializer profile.
129    pub derivation_digest: String,
130    /// SHA-256 digest of the validated raw ext4 bytes.
131    pub artifact_digest: String,
132    /// Pure-Rust materializer ABI.
133    pub materializer_abi: u32,
134    /// Deterministic ext4 UUID as lowercase hexadecimal.
135    pub uuid: String,
136    /// Logical sparse image size.
137    pub virtual_size_bytes: u64,
138    /// Unique inode count in the materialized rootfs.
139    pub inode_count: u64,
140    /// Unique regular-file content bytes.
141    pub content_bytes: u64,
142}
143
144//--------------------------------------------------------------------------------------------------
145// Methods
146//--------------------------------------------------------------------------------------------------
147
148impl GlobalCache {
149    /// Create a new GlobalCache using the provided cache directory.
150    ///
151    /// Creates all subdirectories if they don't exist.
152    pub fn new(cache_dir: &Path) -> ImageResult<Self> {
153        let layers_dir = cache_dir.join(LAYERS_DIR);
154        let fsmeta_dir = cache_dir.join(FSMETA_DIR);
155        let vmdk_dir = cache_dir.join(VMDK_DIR);
156        let flat_dir = cache_dir.join(FLAT_DIR);
157        let flat_refs_dir = flat_dir.join(FLAT_REFS_DIR);
158        let flat_blobs_dir = flat_dir.join(FLAT_BLOBS_DIR);
159        let flat_locks_dir = flat_dir.join(FLAT_LOCKS_DIR);
160        let manifests_dir = cache_dir.join(MANIFESTS_DIR);
161        let tmp_dir = cache_dir.join(TMP_DIR);
162
163        for dir in [
164            &layers_dir,
165            &fsmeta_dir,
166            &vmdk_dir,
167            &flat_refs_dir,
168            &flat_blobs_dir,
169            &flat_locks_dir,
170            &manifests_dir,
171            &tmp_dir,
172        ] {
173            std::fs::create_dir_all(dir).map_err(|e| ImageError::Cache {
174                path: dir.clone(),
175                source: e,
176            })?;
177        }
178
179        Ok(Self {
180            layers_dir,
181            fsmeta_dir,
182            vmdk_dir,
183            flat_refs_dir,
184            flat_blobs_dir,
185            flat_locks_dir,
186            manifests_dir,
187            tmp_dir,
188        })
189    }
190
191    /// Create a new GlobalCache using async filesystem operations.
192    pub async fn new_async(cache_dir: &Path) -> ImageResult<Self> {
193        let layers_dir = cache_dir.join(LAYERS_DIR);
194        let fsmeta_dir = cache_dir.join(FSMETA_DIR);
195        let vmdk_dir = cache_dir.join(VMDK_DIR);
196        let flat_dir = cache_dir.join(FLAT_DIR);
197        let flat_refs_dir = flat_dir.join(FLAT_REFS_DIR);
198        let flat_blobs_dir = flat_dir.join(FLAT_BLOBS_DIR);
199        let flat_locks_dir = flat_dir.join(FLAT_LOCKS_DIR);
200        let manifests_dir = cache_dir.join(MANIFESTS_DIR);
201        let tmp_dir = cache_dir.join(TMP_DIR);
202
203        for dir in [
204            &layers_dir,
205            &fsmeta_dir,
206            &vmdk_dir,
207            &flat_refs_dir,
208            &flat_blobs_dir,
209            &flat_locks_dir,
210            &manifests_dir,
211            &tmp_dir,
212        ] {
213            tokio::fs::create_dir_all(dir)
214                .await
215                .map_err(|e| ImageError::Cache {
216                    path: dir.clone(),
217                    source: e,
218                })?;
219        }
220
221        Ok(Self {
222            layers_dir,
223            fsmeta_dir,
224            vmdk_dir,
225            flat_refs_dir,
226            flat_blobs_dir,
227            flat_locks_dir,
228            manifests_dir,
229            tmp_dir,
230        })
231    }
232
233    // ── Layer EROFS paths (keyed by diff_id) ─────────────────────────
234
235    /// Root layer EROFS cache directory.
236    pub fn layers_dir(&self) -> &Path {
237        &self.layers_dir
238    }
239
240    /// Path to the per-layer EROFS image for a given diff_id.
241    pub fn layer_erofs_path(&self, diff_id: &Digest) -> PathBuf {
242        self.layers_dir
243            .join(format!("{}.erofs", diff_id.to_path_safe()))
244    }
245
246    /// Path to the materialization lock for a layer EROFS image.
247    pub fn layer_erofs_lock_path(&self, diff_id: &Digest) -> PathBuf {
248        self.layers_dir
249            .join(format!("{}.erofs.lock", diff_id.to_path_safe()))
250    }
251
252    /// Check if a layer EROFS image exists.
253    pub fn is_layer_materialized(&self, diff_id: &Digest) -> bool {
254        is_valid_erofs_artifact(&self.layer_erofs_path(diff_id))
255    }
256
257    /// Check if all given layer diff_ids have materialized EROFS images.
258    pub fn all_layers_materialized(&self, diff_ids: &[Digest]) -> bool {
259        diff_ids.iter().all(|d| self.is_layer_materialized(d))
260    }
261
262    // ── fsmeta EROFS paths (keyed by manifest digest) ─────────────────
263
264    /// Root fsmeta EROFS cache directory.
265    pub fn fsmeta_dir(&self) -> &Path {
266        &self.fsmeta_dir
267    }
268
269    /// Path to the fsmeta EROFS image for a given manifest digest.
270    pub fn fsmeta_erofs_path(&self, manifest_digest: &Digest) -> PathBuf {
271        self.fsmeta_dir
272            .join(format!("{}.erofs", manifest_digest.to_path_safe()))
273    }
274
275    /// Path to the materialization lock for a fsmeta EROFS image.
276    pub fn fsmeta_erofs_lock_path(&self, manifest_digest: &Digest) -> PathBuf {
277        self.fsmeta_dir
278            .join(format!("{}.erofs.lock", manifest_digest.to_path_safe()))
279    }
280
281    /// Check if a fsmeta EROFS image exists.
282    pub fn is_fsmeta_materialized(&self, manifest_digest: &Digest) -> bool {
283        is_valid_erofs_artifact(&self.fsmeta_erofs_path(manifest_digest))
284    }
285
286    // ── VMDK descriptor paths (keyed by manifest digest) ────────────
287
288    /// Root VMDK cache directory.
289    pub fn vmdk_dir(&self) -> &Path {
290        &self.vmdk_dir
291    }
292
293    /// Path to the VMDK descriptor for a given manifest digest.
294    pub fn vmdk_path(&self, manifest_digest: &Digest) -> PathBuf {
295        self.vmdk_dir
296            .join(format!("{}.vmdk", manifest_digest.to_path_safe()))
297    }
298
299    /// Path to the materialization lock for a VMDK descriptor.
300    pub fn vmdk_lock_path(&self, manifest_digest: &Digest) -> PathBuf {
301        self.vmdk_dir
302            .join(format!("{}.vmdk.lock", manifest_digest.to_path_safe()))
303    }
304
305    /// Check if a VMDK descriptor exists for a given manifest digest.
306    pub fn is_vmdk_materialized(&self, manifest_digest: &Digest) -> bool {
307        self.vmdk_path(manifest_digest).exists()
308    }
309
310    // ── Flat ext4 artifact paths (manifest ref → content blob) ───────
311
312    /// Path to the manifest-keyed flat rootfs reference.
313    pub fn flat_ref_path(&self, manifest_digest: &Digest) -> PathBuf {
314        self.flat_refs_dir
315            .join(format!("{}.json", manifest_digest.to_path_safe()))
316    }
317
318    /// Path to the immutable content-addressed raw ext4 artifact.
319    pub fn flat_blob_path(&self, artifact_digest: &Digest) -> PathBuf {
320        self.flat_blobs_dir
321            .join(format!("{}.raw", artifact_digest.to_path_safe()))
322    }
323
324    /// Path to the per-derivation flat materialization lock.
325    pub fn flat_lock_path(&self, derivation_digest: &Digest) -> PathBuf {
326        self.flat_locks_dir
327            .join(format!("{}.lock", derivation_digest.to_path_safe()))
328    }
329
330    /// Same-filesystem work directory for one flat-rootfs derivation.
331    pub fn flat_work_dir(&self, derivation_digest: &Digest) -> PathBuf {
332        self.tmp_dir
333            .join(format!("{}.flat.work", derivation_digest.to_path_safe()))
334    }
335
336    /// Publish a synchronized candidate as an immutable content-addressed blob.
337    pub fn publish_flat_blob(
338        &self,
339        candidate: &Path,
340        artifact_digest: &Digest,
341        expected_size: u64,
342    ) -> ImageResult<PathBuf> {
343        let destination = self.flat_blob_path(artifact_digest);
344        if let Ok(metadata) = std::fs::metadata(&destination) {
345            if metadata.len() != expected_size {
346                return Err(ImageError::Cache {
347                    path: destination,
348                    source: std::io::Error::new(
349                        std::io::ErrorKind::InvalidData,
350                        "content-addressed flat blob has an unexpected size",
351                    ),
352                });
353            }
354            if flat_blob_matches_digest(&destination, artifact_digest)? {
355                let _ = std::fs::remove_file(candidate);
356                return Ok(destination);
357            }
358
359            // A content-addressed name must never retain different bytes. The
360            // candidate has already been synchronized and validated, while a
361            // missing destination makes every existing ref safely miss after
362            // a crash between removal and rename.
363            std::fs::remove_file(&destination).map_err(|source| ImageError::Cache {
364                path: destination.clone(),
365                source,
366            })?;
367        }
368        std::fs::rename(candidate, &destination).map_err(|source| ImageError::Cache {
369            path: destination.clone(),
370            source,
371        })?;
372        sync_directory(&self.flat_blobs_dir)?;
373        Ok(destination)
374    }
375
376    /// Read and validate the manifest-keyed flat rootfs reference.
377    pub fn read_flat_ref(&self, manifest_digest: &Digest) -> ImageResult<Option<FlatRootfsRef>> {
378        let path = self.flat_ref_path(manifest_digest);
379        let data = match std::fs::read_to_string(&path) {
380            Ok(data) => data,
381            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
382            Err(source) => return Err(ImageError::Cache { path, source }),
383        };
384        let reference = match serde_json::from_str::<FlatRootfsRef>(&data) {
385            Ok(reference)
386                if reference.schema == 1
387                    && reference.manifest_digest == manifest_digest.to_string() =>
388            {
389                reference
390            }
391            Ok(_) => return Ok(None),
392            Err(error) => {
393                tracing::warn!(path = %path.display(), %error, "corrupt flat rootfs ref, ignoring");
394                return Ok(None);
395            }
396        };
397        let artifact_digest = match reference.artifact_digest.parse::<Digest>() {
398            Ok(digest) => digest,
399            Err(_) => return Ok(None),
400        };
401        let blob_path = self.flat_blob_path(&artifact_digest);
402        match std::fs::metadata(&blob_path) {
403            Ok(metadata) if metadata.len() == reference.virtual_size_bytes => Ok(Some(reference)),
404            Ok(_) | Err(_) => Ok(None),
405        }
406    }
407
408    /// Atomically replace a flat rootfs reference after its immutable blob is durable.
409    pub fn write_flat_ref(
410        &self,
411        manifest_digest: &Digest,
412        reference: &FlatRootfsRef,
413    ) -> ImageResult<()> {
414        let path = self.flat_ref_path(manifest_digest);
415        let temp_path = path.with_extension("json.part");
416        let payload = serde_json::to_vec_pretty(reference).map_err(|error| {
417            ImageError::ConfigParse(format!("failed to serialize flat rootfs ref: {error}"))
418        })?;
419        let mut temp = std::fs::File::create(&temp_path).map_err(|source| ImageError::Cache {
420            path: temp_path.clone(),
421            source,
422        })?;
423        use std::io::Write;
424        temp.write_all(&payload)
425            .map_err(|source| ImageError::Cache {
426                path: temp_path.clone(),
427                source,
428            })?;
429        temp.sync_all().map_err(|source| ImageError::Cache {
430            path: temp_path.clone(),
431            source,
432        })?;
433        std::fs::rename(&temp_path, &path).map_err(|source| ImageError::Cache {
434            path: path.clone(),
435            source,
436        })?;
437        sync_directory(&self.flat_refs_dir)?;
438        Ok(())
439    }
440
441    // ── Staging/tmp paths (downloads, work dirs) ─────────────────────
442
443    /// Root staging directory.
444    pub fn tmp_dir(&self) -> &Path {
445        &self.tmp_dir
446    }
447
448    /// Path to the partial download file for a blob.
449    pub fn part_path(&self, blob_digest: &Digest) -> PathBuf {
450        self.tmp_dir
451            .join(format!("{}.part", blob_digest.to_path_safe()))
452    }
453
454    /// Path to the download lock file for a blob.
455    pub fn download_lock_path(&self, blob_digest: &Digest) -> PathBuf {
456        self.tmp_dir
457            .join(format!("{}.download.lock", blob_digest.to_path_safe()))
458    }
459
460    /// Path to the materialization work directory for an EROFS build.
461    pub fn work_dir(&self, key: &Digest) -> PathBuf {
462        self.tmp_dir.join(format!("{}.work", key.to_path_safe()))
463    }
464
465    // ── Manifest metadata cache ──────────────────────────────────────
466
467    /// Root manifest metadata directory.
468    pub fn manifests_dir(&self) -> &Path {
469        &self.manifests_dir
470    }
471
472    /// Path to the pull lock file for an image reference.
473    pub fn image_lock_path(&self, reference: &Reference) -> PathBuf {
474        self.manifests_dir
475            .join(format!("{}.lock", image_cache_key(reference)))
476    }
477
478    /// Read cached metadata for an image reference.
479    pub fn read_image_metadata(
480        &self,
481        reference: &Reference,
482    ) -> ImageResult<Option<CachedImageMetadata>> {
483        let path = self.image_metadata_path(reference);
484
485        let data = match std::fs::read_to_string(&path) {
486            Ok(data) => data,
487            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
488            Err(e) => return Err(ImageError::Cache { path, source: e }),
489        };
490
491        parse_cached_image_metadata(&path, &data)
492    }
493
494    /// Read cached metadata for an image reference using async filesystem I/O.
495    pub async fn read_image_metadata_async(
496        &self,
497        reference: &Reference,
498    ) -> ImageResult<Option<CachedImageMetadata>> {
499        let path = self.image_metadata_path(reference);
500
501        let data = match tokio::fs::read_to_string(&path).await {
502            Ok(data) => data,
503            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
504            Err(e) => return Err(ImageError::Cache { path, source: e }),
505        };
506
507        parse_cached_image_metadata(&path, &data)
508    }
509
510    /// Write cached metadata for an image reference.
511    #[cfg_attr(not(test), allow(dead_code))]
512    pub(crate) fn write_image_metadata(
513        &self,
514        reference: &Reference,
515        metadata: &CachedImageMetadata,
516    ) -> ImageResult<()> {
517        let path = self.image_metadata_path(reference);
518        let temp_path = path.with_extension("json.part");
519        let payload = serde_json::to_vec(metadata).map_err(|e| {
520            ImageError::ConfigParse(format!("failed to serialize cached image metadata: {e}"))
521        })?;
522
523        std::fs::write(&temp_path, payload).map_err(|e| ImageError::Cache {
524            path: temp_path.clone(),
525            source: e,
526        })?;
527        std::fs::rename(&temp_path, &path).map_err(|e| ImageError::Cache { path, source: e })?;
528
529        Ok(())
530    }
531
532    /// Write cached metadata for an image reference using async filesystem I/O.
533    pub async fn write_image_metadata_async(
534        &self,
535        reference: &Reference,
536        metadata: &CachedImageMetadata,
537    ) -> ImageResult<()> {
538        let path = self.image_metadata_path(reference);
539        let temp_path = path.with_extension("json.part");
540        let payload = serde_json::to_vec(metadata).map_err(|e| {
541            ImageError::ConfigParse(format!("failed to serialize cached image metadata: {e}"))
542        })?;
543
544        tokio::fs::write(&temp_path, payload)
545            .await
546            .map_err(|e| ImageError::Cache {
547                path: temp_path.clone(),
548                source: e,
549            })?;
550        tokio::fs::rename(&temp_path, &path)
551            .await
552            .map_err(|e| ImageError::Cache { path, source: e })?;
553
554        Ok(())
555    }
556
557    /// Delete cached metadata for an image reference.
558    pub fn delete_image_metadata(&self, reference: &Reference) -> ImageResult<()> {
559        let path = self.image_metadata_path(reference);
560        match std::fs::remove_file(&path) {
561            Ok(()) => Ok(()),
562            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
563            Err(e) => Err(ImageError::Cache { path, source: e }),
564        }
565    }
566
567    /// Delete cached metadata for an image reference using async filesystem I/O.
568    pub async fn delete_image_metadata_async(&self, reference: &Reference) -> ImageResult<()> {
569        let path = self.image_metadata_path(reference);
570        match tokio::fs::remove_file(&path).await {
571            Ok(()) => Ok(()),
572            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
573            Err(e) => Err(ImageError::Cache { path, source: e }),
574        }
575    }
576
577    /// Path to the cached metadata file for an image reference.
578    pub fn image_metadata_path(&self, reference: &Reference) -> PathBuf {
579        self.manifests_dir
580            .join(format!("{}.json", image_cache_key(reference)))
581    }
582
583    // ── Blob cache paths ──────────────────────────────────────────────
584
585    /// Path to the cached compressed tarball for a layer blob.
586    pub fn tar_path(&self, digest: &Digest) -> PathBuf {
587        self.layers_dir
588            .join(format!("{}.tar.gz", digest.to_path_safe()))
589    }
590}
591
592//--------------------------------------------------------------------------------------------------
593// Functions
594//--------------------------------------------------------------------------------------------------
595
596fn image_cache_key(reference: &Reference) -> String {
597    let mut hasher = Sha256::new();
598    hasher.update(reference.to_string().as_bytes());
599    hex::encode(hasher.finalize())
600}
601
602#[cfg(unix)]
603fn sync_directory(path: &Path) -> ImageResult<()> {
604    let directory = std::fs::File::open(path).map_err(|source| ImageError::Cache {
605        path: path.to_path_buf(),
606        source,
607    })?;
608    directory.sync_all().map_err(|source| ImageError::Cache {
609        path: path.to_path_buf(),
610        source,
611    })
612}
613
614#[cfg(not(unix))]
615fn sync_directory(_path: &Path) -> ImageResult<()> {
616    Ok(())
617}
618
619pub(crate) fn parse_cached_image_metadata(
620    path: &Path,
621    data: &str,
622) -> ImageResult<Option<CachedImageMetadata>> {
623    match serde_json::from_str::<CachedImageMetadata>(data) {
624        Ok(metadata) => Ok(Some(metadata)),
625        Err(e) => {
626            tracing::warn!(
627                path = %path.display(),
628                error = %e,
629                "corrupt image metadata cache, ignoring"
630            );
631            Ok(None)
632        }
633    }
634}
635
636pub(crate) fn is_valid_erofs_artifact(path: &Path) -> bool {
637    let Ok(meta) = std::fs::metadata(path) else {
638        return false;
639    };
640    let len = meta.len();
641    if !meta.is_file() || len == 0 || len % EROFS_ALIGNMENT_BYTES != 0 {
642        return false;
643    }
644
645    // Length alone accepts any aligned garbage as a cache hit. Parse the
646    // superblock and root inode so corrupt cached layers are re-materialized
647    // instead of failing later while composing a flat rootfs or booting a VM.
648    let Ok(file) = std::fs::File::open(path) else {
649        return false;
650    };
651    let Ok(mut reader) = ErofsReader::new(file) else {
652        return false;
653    };
654    reader.root_directory_metadata().is_ok()
655}
656
657pub(crate) async fn is_valid_erofs_artifact_async(path: &Path) -> bool {
658    let path = path.to_path_buf();
659    tokio::task::spawn_blocking(move || is_valid_erofs_artifact(&path))
660        .await
661        .unwrap_or(false)
662}
663
664fn flat_blob_matches_digest(path: &Path, expected: &Digest) -> ImageResult<bool> {
665    let mut file = std::fs::File::open(path).map_err(|source| ImageError::Cache {
666        path: path.to_path_buf(),
667        source,
668    })?;
669    let mut hasher = Sha256::new();
670    let mut buffer = [0u8; 1024 * 1024];
671    loop {
672        let read = file.read(&mut buffer).map_err(|source| ImageError::Cache {
673            path: path.to_path_buf(),
674            source,
675        })?;
676        if read == 0 {
677            break;
678        }
679        hasher.update(&buffer[..read]);
680    }
681    Ok(format!("sha256:{}", hex::encode(hasher.finalize())) == expected.to_string())
682}
683
684//--------------------------------------------------------------------------------------------------
685// Tests
686//--------------------------------------------------------------------------------------------------
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn digest(byte: char) -> Digest {
693        format!("sha256:{}", byte.to_string().repeat(64))
694            .parse()
695            .unwrap()
696    }
697
698    #[test]
699    fn flat_cache_separates_manifest_refs_from_content_blobs() {
700        let directory = tempfile::tempdir().unwrap();
701        let cache = GlobalCache::new(directory.path()).unwrap();
702        let manifest = digest('a');
703        let derivation = digest('b');
704        let artifact = digest('c');
705
706        assert!(cache.flat_ref_path(&manifest).ends_with(
707            "flat/refs/sha256_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"
708        ));
709        assert!(cache.flat_blob_path(&artifact).ends_with(
710            "flat/blobs/sha256_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.raw"
711        ));
712        assert!(cache.flat_lock_path(&derivation).ends_with(
713            "flat/locks/sha256_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.lock"
714        ));
715
716        let blob_path = cache.flat_blob_path(&artifact);
717        let blob = std::fs::File::create(&blob_path).unwrap();
718        blob.set_len(4096).unwrap();
719        let reference = FlatRootfsRef {
720            schema: 1,
721            manifest_digest: manifest.to_string(),
722            derivation_digest: derivation.to_string(),
723            artifact_digest: artifact.to_string(),
724            materializer_abi: 1,
725            uuid: "00".repeat(16),
726            virtual_size_bytes: 4096,
727            inode_count: 2,
728            content_bytes: 7,
729        };
730        cache.write_flat_ref(&manifest, &reference).unwrap();
731
732        assert_eq!(cache.read_flat_ref(&manifest).unwrap(), Some(reference));
733    }
734
735    #[test]
736    fn aligned_garbage_is_not_a_valid_erofs_cache_artifact() {
737        let directory = tempfile::tempdir().unwrap();
738        let path = directory.path().join("corrupt.erofs");
739        let file = std::fs::File::create(&path).unwrap();
740        file.set_len(EROFS_ALIGNMENT_BYTES).unwrap();
741
742        assert!(!is_valid_erofs_artifact(&path));
743    }
744
745    #[test]
746    fn flat_ref_must_name_the_manifest_that_indexes_it() {
747        let directory = tempfile::tempdir().unwrap();
748        let cache = GlobalCache::new(directory.path()).unwrap();
749        let indexed_manifest = digest('a');
750        let wrong_manifest = digest('b');
751        let artifact = digest('c');
752        std::fs::write(cache.flat_blob_path(&artifact), [0u8; 8]).unwrap();
753        let reference = FlatRootfsRef {
754            schema: 1,
755            manifest_digest: wrong_manifest.to_string(),
756            derivation_digest: digest('d').to_string(),
757            artifact_digest: artifact.to_string(),
758            materializer_abi: 1,
759            uuid: "00".repeat(16),
760            virtual_size_bytes: 8,
761            inode_count: 2,
762            content_bytes: 0,
763        };
764        cache.write_flat_ref(&indexed_manifest, &reference).unwrap();
765
766        assert_eq!(cache.read_flat_ref(&indexed_manifest).unwrap(), None);
767    }
768
769    #[test]
770    fn publishing_replaces_same_size_blob_with_wrong_content() {
771        let directory = tempfile::tempdir().unwrap();
772        let cache = GlobalCache::new(directory.path()).unwrap();
773        let candidate = directory.path().join("candidate.raw");
774        let expected_bytes = b"good";
775        std::fs::write(&candidate, expected_bytes).unwrap();
776        let expected: Digest = format!("sha256:{}", hex::encode(Sha256::digest(expected_bytes)))
777            .parse()
778            .unwrap();
779        let destination = cache.flat_blob_path(&expected);
780        std::fs::write(&destination, b"evil").unwrap();
781
782        cache
783            .publish_flat_blob(&candidate, &expected, expected_bytes.len() as u64)
784            .unwrap();
785
786        assert_eq!(std::fs::read(destination).unwrap(), expected_bytes);
787    }
788}