Skip to main content

microsandbox_image/
store.rs

1//! Global on-disk image and layer cache.
2
3use std::path::{Path, PathBuf};
4
5use oci_client::Reference;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest as Sha2Digest, Sha256};
8
9use crate::{
10    config::ImageConfig,
11    digest::Digest,
12    error::{ImageError, ImageResult},
13};
14
15//--------------------------------------------------------------------------------------------------
16// Constants
17//--------------------------------------------------------------------------------------------------
18
19/// Subdirectory for per-layer EROFS images (keyed by diff_id).
20const LAYERS_DIR: &str = "layers";
21
22/// Subdirectory for fsmeta EROFS images (keyed by manifest digest).
23const FSMETA_DIR: &str = "fsmeta";
24
25/// Subdirectory for VMDK descriptors (keyed by manifest digest).
26const VMDK_DIR: &str = "vmdk";
27
28/// Subdirectory for cached manifest + config metadata.
29const MANIFESTS_DIR: &str = "manifests";
30
31/// Subdirectory for transient staging (downloads, work dirs).
32const TMP_DIR: &str = "tmp";
33
34/// EROFS images are emitted in 4 KiB filesystem blocks.
35const EROFS_ALIGNMENT_BYTES: u64 = 4096;
36
37//--------------------------------------------------------------------------------------------------
38// Types
39//--------------------------------------------------------------------------------------------------
40
41/// On-disk global cache for OCI layers and EROFS images.
42///
43/// Layout:
44/// ```text
45/// ~/.microsandbox/cache/manifests/<sha256-of-ref>.json       # manifest + config metadata
46/// ~/.microsandbox/cache/tmp/<blob>.part                      # partial downloads
47/// ~/.microsandbox/cache/tmp/<blob>.download.lock             # download flock files
48/// ~/.microsandbox/cache/tmp/<blob>.work/                     # materialization work dirs
49/// ~/.microsandbox/cache/layers/<diff_id_safe>.erofs          # per-layer EROFS
50/// ~/.microsandbox/cache/layers/<diff_id_safe>.erofs.lock     # materialization flock
51/// ~/.microsandbox/cache/fsmeta/<manifest_safe>.erofs         # fsmeta EROFS (fsmerge metadata)
52/// ~/.microsandbox/cache/fsmeta/<manifest_safe>.erofs.lock    # materialization flock
53/// ~/.microsandbox/cache/vmdk/<manifest_safe>.vmdk            # VMDK descriptor
54/// ~/.microsandbox/cache/vmdk/<manifest_safe>.vmdk.lock       # materialization flock
55/// ```
56pub struct GlobalCache {
57    /// Root of the layer EROFS cache (`~/.microsandbox/cache/layers/`).
58    layers_dir: PathBuf,
59
60    /// Root of the fsmeta EROFS cache (`~/.microsandbox/cache/fsmeta/`).
61    fsmeta_dir: PathBuf,
62
63    /// Root of the VMDK descriptor cache (`~/.microsandbox/cache/vmdk/`).
64    vmdk_dir: PathBuf,
65
66    /// Root of the manifest metadata cache (`~/.microsandbox/cache/manifests/`).
67    manifests_dir: PathBuf,
68
69    /// Root of the transient staging area (`~/.microsandbox/cache/tmp/`).
70    tmp_dir: PathBuf,
71}
72
73/// Cached metadata for a pulled image reference.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CachedImageMetadata {
76    /// Content-addressable digest of the resolved manifest.
77    pub manifest_digest: String,
78    /// Content-addressable digest of the config blob.
79    pub config_digest: String,
80    /// Parsed OCI image configuration.
81    pub config: ImageConfig,
82    /// Layer metadata in bottom-to-top order.
83    pub layers: Vec<CachedLayerMetadata>,
84}
85
86/// Cached metadata for a single layer descriptor.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CachedLayerMetadata {
89    /// Compressed layer digest from the manifest (blob digest).
90    pub digest: String,
91    /// OCI media type of the layer blob.
92    pub media_type: Option<String>,
93    /// Compressed blob size in bytes.
94    pub size_bytes: Option<u64>,
95    /// Uncompressed diff ID from the image config.
96    pub diff_id: String,
97}
98
99//--------------------------------------------------------------------------------------------------
100// Methods
101//--------------------------------------------------------------------------------------------------
102
103impl GlobalCache {
104    /// Create a new GlobalCache using the provided cache directory.
105    ///
106    /// Creates all subdirectories if they don't exist.
107    pub fn new(cache_dir: &Path) -> ImageResult<Self> {
108        let layers_dir = cache_dir.join(LAYERS_DIR);
109        let fsmeta_dir = cache_dir.join(FSMETA_DIR);
110        let vmdk_dir = cache_dir.join(VMDK_DIR);
111        let manifests_dir = cache_dir.join(MANIFESTS_DIR);
112        let tmp_dir = cache_dir.join(TMP_DIR);
113
114        for dir in [
115            &layers_dir,
116            &fsmeta_dir,
117            &vmdk_dir,
118            &manifests_dir,
119            &tmp_dir,
120        ] {
121            std::fs::create_dir_all(dir).map_err(|e| ImageError::Cache {
122                path: dir.clone(),
123                source: e,
124            })?;
125        }
126
127        Ok(Self {
128            layers_dir,
129            fsmeta_dir,
130            vmdk_dir,
131            manifests_dir,
132            tmp_dir,
133        })
134    }
135
136    /// Create a new GlobalCache using async filesystem operations.
137    pub async fn new_async(cache_dir: &Path) -> ImageResult<Self> {
138        let layers_dir = cache_dir.join(LAYERS_DIR);
139        let fsmeta_dir = cache_dir.join(FSMETA_DIR);
140        let vmdk_dir = cache_dir.join(VMDK_DIR);
141        let manifests_dir = cache_dir.join(MANIFESTS_DIR);
142        let tmp_dir = cache_dir.join(TMP_DIR);
143
144        for dir in [
145            &layers_dir,
146            &fsmeta_dir,
147            &vmdk_dir,
148            &manifests_dir,
149            &tmp_dir,
150        ] {
151            tokio::fs::create_dir_all(dir)
152                .await
153                .map_err(|e| ImageError::Cache {
154                    path: dir.clone(),
155                    source: e,
156                })?;
157        }
158
159        Ok(Self {
160            layers_dir,
161            fsmeta_dir,
162            vmdk_dir,
163            manifests_dir,
164            tmp_dir,
165        })
166    }
167
168    // ── Layer EROFS paths (keyed by diff_id) ─────────────────────────
169
170    /// Root layer EROFS cache directory.
171    pub fn layers_dir(&self) -> &Path {
172        &self.layers_dir
173    }
174
175    /// Path to the per-layer EROFS image for a given diff_id.
176    pub fn layer_erofs_path(&self, diff_id: &Digest) -> PathBuf {
177        self.layers_dir
178            .join(format!("{}.erofs", diff_id.to_path_safe()))
179    }
180
181    /// Path to the materialization lock for a layer EROFS image.
182    pub fn layer_erofs_lock_path(&self, diff_id: &Digest) -> PathBuf {
183        self.layers_dir
184            .join(format!("{}.erofs.lock", diff_id.to_path_safe()))
185    }
186
187    /// Check if a layer EROFS image exists.
188    pub fn is_layer_materialized(&self, diff_id: &Digest) -> bool {
189        is_valid_erofs_artifact(&self.layer_erofs_path(diff_id))
190    }
191
192    /// Check if all given layer diff_ids have materialized EROFS images.
193    pub fn all_layers_materialized(&self, diff_ids: &[Digest]) -> bool {
194        diff_ids.iter().all(|d| self.is_layer_materialized(d))
195    }
196
197    // ── fsmeta EROFS paths (keyed by manifest digest) ─────────────────
198
199    /// Root fsmeta EROFS cache directory.
200    pub fn fsmeta_dir(&self) -> &Path {
201        &self.fsmeta_dir
202    }
203
204    /// Path to the fsmeta EROFS image for a given manifest digest.
205    pub fn fsmeta_erofs_path(&self, manifest_digest: &Digest) -> PathBuf {
206        self.fsmeta_dir
207            .join(format!("{}.erofs", manifest_digest.to_path_safe()))
208    }
209
210    /// Path to the materialization lock for a fsmeta EROFS image.
211    pub fn fsmeta_erofs_lock_path(&self, manifest_digest: &Digest) -> PathBuf {
212        self.fsmeta_dir
213            .join(format!("{}.erofs.lock", manifest_digest.to_path_safe()))
214    }
215
216    /// Check if a fsmeta EROFS image exists.
217    pub fn is_fsmeta_materialized(&self, manifest_digest: &Digest) -> bool {
218        is_valid_erofs_artifact(&self.fsmeta_erofs_path(manifest_digest))
219    }
220
221    // ── VMDK descriptor paths (keyed by manifest digest) ────────────
222
223    /// Root VMDK cache directory.
224    pub fn vmdk_dir(&self) -> &Path {
225        &self.vmdk_dir
226    }
227
228    /// Path to the VMDK descriptor for a given manifest digest.
229    pub fn vmdk_path(&self, manifest_digest: &Digest) -> PathBuf {
230        self.vmdk_dir
231            .join(format!("{}.vmdk", manifest_digest.to_path_safe()))
232    }
233
234    /// Path to the materialization lock for a VMDK descriptor.
235    pub fn vmdk_lock_path(&self, manifest_digest: &Digest) -> PathBuf {
236        self.vmdk_dir
237            .join(format!("{}.vmdk.lock", manifest_digest.to_path_safe()))
238    }
239
240    /// Check if a VMDK descriptor exists for a given manifest digest.
241    pub fn is_vmdk_materialized(&self, manifest_digest: &Digest) -> bool {
242        self.vmdk_path(manifest_digest).exists()
243    }
244
245    // ── Staging/tmp paths (downloads, work dirs) ─────────────────────
246
247    /// Root staging directory.
248    pub fn tmp_dir(&self) -> &Path {
249        &self.tmp_dir
250    }
251
252    /// Path to the partial download file for a blob.
253    pub fn part_path(&self, blob_digest: &Digest) -> PathBuf {
254        self.tmp_dir
255            .join(format!("{}.part", blob_digest.to_path_safe()))
256    }
257
258    /// Path to the download lock file for a blob.
259    pub fn download_lock_path(&self, blob_digest: &Digest) -> PathBuf {
260        self.tmp_dir
261            .join(format!("{}.download.lock", blob_digest.to_path_safe()))
262    }
263
264    /// Path to the materialization work directory for an EROFS build.
265    pub fn work_dir(&self, key: &Digest) -> PathBuf {
266        self.tmp_dir.join(format!("{}.work", key.to_path_safe()))
267    }
268
269    // ── Manifest metadata cache ──────────────────────────────────────
270
271    /// Root manifest metadata directory.
272    pub fn manifests_dir(&self) -> &Path {
273        &self.manifests_dir
274    }
275
276    /// Path to the pull lock file for an image reference.
277    pub fn image_lock_path(&self, reference: &Reference) -> PathBuf {
278        self.manifests_dir
279            .join(format!("{}.lock", image_cache_key(reference)))
280    }
281
282    /// Read cached metadata for an image reference.
283    pub fn read_image_metadata(
284        &self,
285        reference: &Reference,
286    ) -> ImageResult<Option<CachedImageMetadata>> {
287        let path = self.image_metadata_path(reference);
288
289        let data = match std::fs::read_to_string(&path) {
290            Ok(data) => data,
291            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
292            Err(e) => return Err(ImageError::Cache { path, source: e }),
293        };
294
295        parse_cached_image_metadata(&path, &data)
296    }
297
298    /// Read cached metadata for an image reference using async filesystem I/O.
299    pub async fn read_image_metadata_async(
300        &self,
301        reference: &Reference,
302    ) -> ImageResult<Option<CachedImageMetadata>> {
303        let path = self.image_metadata_path(reference);
304
305        let data = match tokio::fs::read_to_string(&path).await {
306            Ok(data) => data,
307            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
308            Err(e) => return Err(ImageError::Cache { path, source: e }),
309        };
310
311        parse_cached_image_metadata(&path, &data)
312    }
313
314    /// Write cached metadata for an image reference.
315    #[cfg_attr(not(test), allow(dead_code))]
316    pub(crate) fn write_image_metadata(
317        &self,
318        reference: &Reference,
319        metadata: &CachedImageMetadata,
320    ) -> ImageResult<()> {
321        let path = self.image_metadata_path(reference);
322        let temp_path = path.with_extension("json.part");
323        let payload = serde_json::to_vec(metadata).map_err(|e| {
324            ImageError::ConfigParse(format!("failed to serialize cached image metadata: {e}"))
325        })?;
326
327        std::fs::write(&temp_path, payload).map_err(|e| ImageError::Cache {
328            path: temp_path.clone(),
329            source: e,
330        })?;
331        std::fs::rename(&temp_path, &path).map_err(|e| ImageError::Cache { path, source: e })?;
332
333        Ok(())
334    }
335
336    /// Write cached metadata for an image reference using async filesystem I/O.
337    pub(crate) async fn write_image_metadata_async(
338        &self,
339        reference: &Reference,
340        metadata: &CachedImageMetadata,
341    ) -> ImageResult<()> {
342        let path = self.image_metadata_path(reference);
343        let temp_path = path.with_extension("json.part");
344        let payload = serde_json::to_vec(metadata).map_err(|e| {
345            ImageError::ConfigParse(format!("failed to serialize cached image metadata: {e}"))
346        })?;
347
348        tokio::fs::write(&temp_path, payload)
349            .await
350            .map_err(|e| ImageError::Cache {
351                path: temp_path.clone(),
352                source: e,
353            })?;
354        tokio::fs::rename(&temp_path, &path)
355            .await
356            .map_err(|e| ImageError::Cache { path, source: e })?;
357
358        Ok(())
359    }
360
361    /// Delete cached metadata for an image reference.
362    pub fn delete_image_metadata(&self, reference: &Reference) -> ImageResult<()> {
363        let path = self.image_metadata_path(reference);
364        match std::fs::remove_file(&path) {
365            Ok(()) => Ok(()),
366            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
367            Err(e) => Err(ImageError::Cache { path, source: e }),
368        }
369    }
370
371    /// Delete cached metadata for an image reference using async filesystem I/O.
372    pub async fn delete_image_metadata_async(&self, reference: &Reference) -> ImageResult<()> {
373        let path = self.image_metadata_path(reference);
374        match tokio::fs::remove_file(&path).await {
375            Ok(()) => Ok(()),
376            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
377            Err(e) => Err(ImageError::Cache { path, source: e }),
378        }
379    }
380
381    /// Path to the cached metadata file for an image reference.
382    fn image_metadata_path(&self, reference: &Reference) -> PathBuf {
383        self.manifests_dir
384            .join(format!("{}.json", image_cache_key(reference)))
385    }
386
387    // ── Blob cache paths ──────────────────────────────────────────────
388
389    /// Path to the cached compressed tarball for a layer blob.
390    pub fn tar_path(&self, digest: &Digest) -> PathBuf {
391        self.layers_dir
392            .join(format!("{}.tar.gz", digest.to_path_safe()))
393    }
394}
395
396//--------------------------------------------------------------------------------------------------
397// Functions
398//--------------------------------------------------------------------------------------------------
399
400fn image_cache_key(reference: &Reference) -> String {
401    let mut hasher = Sha256::new();
402    hasher.update(reference.to_string().as_bytes());
403    hex::encode(hasher.finalize())
404}
405
406fn parse_cached_image_metadata(
407    path: &Path,
408    data: &str,
409) -> ImageResult<Option<CachedImageMetadata>> {
410    match serde_json::from_str::<CachedImageMetadata>(data) {
411        Ok(metadata) => Ok(Some(metadata)),
412        Err(e) => {
413            tracing::warn!(
414                path = %path.display(),
415                error = %e,
416                "corrupt image metadata cache, ignoring"
417            );
418            Ok(None)
419        }
420    }
421}
422
423pub(crate) fn is_valid_erofs_artifact(path: &Path) -> bool {
424    match std::fs::metadata(path) {
425        Ok(meta) => {
426            let len = meta.len();
427            len > 0 && len % EROFS_ALIGNMENT_BYTES == 0
428        }
429        Err(_) => false,
430    }
431}
432
433pub(crate) async fn is_valid_erofs_artifact_async(path: &Path) -> bool {
434    match tokio::fs::metadata(path).await {
435        Ok(meta) => {
436            let len = meta.len();
437            len > 0 && len % EROFS_ALIGNMENT_BYTES == 0
438        }
439        Err(_) => false,
440    }
441}