Skip to main content

oci_image/
store.rs

1use std::fs::{self, File};
2use std::io::{self, Read, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::digest::Hasher;
8use crate::error::Context;
9use crate::{apply_layer_file, Digest, Error, Result, UnpackOptions};
10
11/// A content-addressed store of layer blobs, unpacked layers and images:
12///
13/// ```text
14/// <root>/blobs/sha256/<hex>   verified blobs, one copy each
15/// <root>/layers/<diff_id>/    one layer unpacked once, in overlayfs format
16///                             (whiteouts as 0/0 devices, opaque xattrs);
17///                             shared by every image that has it
18/// <root>/images/<id>.json     an image: its layers, bottom first; the
19///                             refcount of a layer is the images naming it
20/// <root>/rootfs/<chain>/      optional flattened rootfs ([`LayerStore::rootfs`])
21///                             for tools that want one tree; not used by
22///                             overlays and not collected by gc
23/// <root>/tmp/                 downloads and unpacks in progress
24/// ```
25///
26/// A container rootfs is an [`crate::Overlay`] over
27/// [`LayerStore::image_layers`]; an updated image unpacks only its new layers.
28///
29/// Everything lands by rename, so a crash leaves only `tmp/` debris.
30#[derive(Debug, Clone)]
31pub struct LayerStore {
32    root: PathBuf,
33}
34
35impl LayerStore {
36    /// Open (and create) a store at `root`.
37    pub fn open(root: &Path) -> Result<Self> {
38        for d in ["blobs/sha256", "layers", "images", "rootfs", "tmp"] {
39            let p = root.join(d);
40            fs::create_dir_all(&p).ctx(|| format!("creating {}", p.display()))?;
41        }
42        Ok(Self {
43            root: root.to_path_buf(),
44        })
45    }
46
47    pub fn root(&self) -> &Path {
48        &self.root
49    }
50
51    pub fn blob_path(&self, digest: &Digest) -> PathBuf {
52        self.root.join("blobs/sha256").join(digest.hex())
53    }
54
55    pub fn has_blob(&self, digest: &Digest) -> bool {
56        self.blob_path(digest).is_file()
57    }
58
59    /// Store everything `reader` yields as `digest`; refused (and nothing
60    /// kept) if the bytes hash to something else.
61    pub fn put_blob(&self, digest: &Digest, mut reader: impl Read) -> Result<PathBuf> {
62        let mut w = self.blob_writer()?;
63        let mut buf = vec![0u8; 1 << 16];
64        loop {
65            let n = reader.read(&mut buf).ctx(|| format!("reading {digest}"))?;
66            if n == 0 {
67                break;
68            }
69            w.write(&buf[..n])?;
70        }
71        w.commit(digest)
72    }
73
74    /// A blob that arrives in chunks (a registry download).
75    pub fn blob_writer(&self) -> Result<BlobWriter> {
76        let path = self.tmp_path("blob");
77        let file = File::create(&path).ctx(|| format!("creating {}", path.display()))?;
78        Ok(BlobWriter {
79            file: Some(io::BufWriter::with_capacity(1 << 20, file)),
80            path,
81            hasher: Hasher::default(),
82            dest: self.root.join("blobs/sha256"),
83        })
84    }
85
86    /// The rootfs for `layers` (bottom first), unpacked on first use.
87    pub fn rootfs(&self, layers: &[Digest], opts: &UnpackOptions) -> Result<PathBuf> {
88        let dir = self.rootfs_path(layers);
89        if dir.is_dir() {
90            return Ok(dir);
91        }
92        if let Some(missing) = layers.iter().find(|d| !self.has_blob(d)) {
93            return Err(Error::MissingBlob(missing.clone()));
94        }
95        let tmp = match self.unpack(layers, opts) {
96            // A gzip layer the parallel decoder refused (or decoded to bytes
97            // that fail the trailer check): start over with the streaming
98            // decoder, which is authoritative.
99            Err(_) if opts.parallel_gzip => self.unpack(
100                layers,
101                &UnpackOptions {
102                    parallel_gzip: false,
103                    ..opts.clone()
104                },
105            )?,
106            r => r?,
107        };
108        if fs::rename(&tmp, &dir).is_err() {
109            // Another unpack of the same chain won the race.
110            let _ = fs::remove_dir_all(&tmp);
111            if !dir.is_dir() {
112                return Err(Error::Invalid(format!("could not place {}", dir.display())));
113            }
114        }
115        Ok(dir)
116    }
117
118    /// Apply `layers` onto a fresh directory under `tmp/`.
119    fn unpack(&self, layers: &[Digest], opts: &UnpackOptions) -> Result<PathBuf> {
120        let tmp = self.tmp_path("rootfs");
121        fs::create_dir_all(&tmp).ctx(|| format!("creating {}", tmp.display()))?;
122        let unpacked = layers
123            .iter()
124            .try_for_each(|d| apply_layer_file(&self.blob_path(d), &tmp, opts).map(drop));
125        match unpacked {
126            Ok(()) => Ok(tmp),
127            Err(e) => {
128                let _ = fs::remove_dir_all(&tmp);
129                Err(e)
130            }
131        }
132    }
133
134    /// Where the rootfs for `layers` lives (whether or not it exists yet).
135    pub fn rootfs_path(&self, layers: &[Digest]) -> PathBuf {
136        self.root.join("rootfs").join(chain_id(layers).hex())
137    }
138
139    fn tmp_path(&self, kind: &str) -> PathBuf {
140        static N: AtomicU64 = AtomicU64::new(0);
141        let nanos = SystemTime::now()
142            .duration_since(UNIX_EPOCH)
143            .map_or(0, |d| d.as_nanos());
144        self.root.join("tmp").join(format!(
145            "{kind}-{}-{}-{nanos}",
146            std::process::id(),
147            N.fetch_add(1, Ordering::Relaxed)
148        ))
149    }
150}
151
152/// The id of a stack of layers: sha256 over their digests, one per line.
153pub fn chain_id(layers: &[Digest]) -> Digest {
154    let mut h = Hasher::default();
155    for d in layers {
156        h.update(d.to_string().as_bytes());
157        h.update(b"\n");
158    }
159    h.finish()
160}
161
162/// A blob being written; hashed as it goes, placed by [`BlobWriter::commit`],
163/// removed if dropped uncommitted.
164pub struct BlobWriter {
165    file: Option<io::BufWriter<File>>,
166    path: PathBuf,
167    hasher: Hasher,
168    dest: PathBuf,
169}
170
171impl BlobWriter {
172    pub fn write(&mut self, bytes: &[u8]) -> Result<()> {
173        self.hasher.update(bytes);
174        let f = self.file.as_mut().expect("open until commit");
175        f.write_all(bytes)
176            .ctx(|| format!("writing {}", self.path.display()))
177    }
178
179    /// Verify against `digest` and move into the store.
180    pub fn commit(mut self, digest: &Digest) -> Result<PathBuf> {
181        let f = self.file.take().expect("open until commit");
182        f.into_inner()
183            .map_err(|e| e.into_error())
184            .ctx(|| format!("writing {}", self.path.display()))?;
185        digest.verify(&std::mem::take(&mut self.hasher).finish())?;
186        let dest = self.dest.join(digest.hex());
187        fs::rename(&self.path, &dest).ctx(|| format!("placing {digest}"))?;
188        Ok(dest)
189    }
190}
191
192impl Drop for BlobWriter {
193    fn drop(&mut self) {
194        let _ = fs::remove_file(&self.path);
195    }
196}
197
198/// One layer of an image: its blob (compressed) digest and its diff_id (the
199/// digest of the uncompressed tar, from the image config).
200#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201pub struct ImageLayer {
202    pub blob: Digest,
203    pub diff_id: Digest,
204}
205
206/// What [`LayerStore::gc`] removed.
207#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct GcStats {
209    pub layers: usize,
210    pub blobs: usize,
211}
212
213#[derive(serde::Serialize, serde::Deserialize)]
214struct ImageRecord {
215    layers: Vec<ImageLayer>,
216}
217
218impl LayerStore {
219    /// Where the unpacked directory of a layer lives.
220    pub fn layer_path(&self, diff_id: &Digest) -> PathBuf {
221        self.root.join("layers").join(diff_id.hex())
222    }
223
224    pub fn has_layer(&self, diff_id: &Digest) -> bool {
225        self.layer_path(diff_id).is_dir()
226    }
227
228    /// The layers not yet unpacked: what a pull still has to fetch.
229    pub fn missing_layers(&self, layers: &[ImageLayer]) -> Vec<ImageLayer> {
230        layers
231            .iter()
232            .filter(|l| !self.has_layer(&l.diff_id))
233            .cloned()
234            .collect()
235    }
236
237    /// Unpack one layer into its own directory (overlayfs format), once.
238    /// The uncompressed stream must hash to `layer.diff_id`.
239    pub fn unpack_layer(&self, layer: &ImageLayer, opts: &UnpackOptions) -> Result<PathBuf> {
240        let dir = self.layer_path(&layer.diff_id);
241        if dir.is_dir() {
242            return Ok(dir);
243        }
244        if !self.has_blob(&layer.blob) {
245            return Err(Error::MissingBlob(layer.blob.clone()));
246        }
247        let attempt = |o: &UnpackOptions| -> Result<PathBuf> {
248            let tmp = self.tmp_path("layer");
249            fs::create_dir_all(&tmp).ctx(|| format!("creating {}", tmp.display()))?;
250            let r = crate::layer::apply_layer_file_digest(&self.blob_path(&layer.blob), &tmp, o)
251                .and_then(|(_, d)| layer.diff_id.verify(&d));
252            match r {
253                Ok(()) => Ok(tmp),
254                Err(e) => {
255                    let _ = fs::remove_dir_all(&tmp);
256                    Err(e)
257                }
258            }
259        };
260        let o = UnpackOptions {
261            layout: match opts.layout {
262                crate::Layout::Flatten => crate::Layout::Overlay(crate::OverlayXattr::Trusted),
263                l => l,
264            },
265            ..opts.clone()
266        };
267        let tmp = match attempt(&o) {
268            Err(e @ Error::DigestMismatch { .. }) => return Err(e),
269            Err(_) if o.parallel_gzip => attempt(&UnpackOptions {
270                parallel_gzip: false,
271                ..o.clone()
272            })?,
273            r => r?,
274        };
275        if fs::rename(&tmp, &dir).is_err() {
276            let _ = fs::remove_dir_all(&tmp);
277            if !dir.is_dir() {
278                return Err(Error::Invalid(format!("could not place {}", dir.display())));
279            }
280        }
281        Ok(dir)
282    }
283
284    /// Record image `id` as `layers` (bottom first), unpacking the layers
285    /// not in the store yet. Returns the layer directories, bottom first.
286    pub fn add_image(
287        &self,
288        id: &Digest,
289        layers: &[ImageLayer],
290        opts: &UnpackOptions,
291    ) -> Result<Vec<PathBuf>> {
292        let dirs = layers
293            .iter()
294            .map(|l| self.unpack_layer(l, opts))
295            .collect::<Result<Vec<_>>>()?;
296        let rec = ImageRecord {
297            layers: layers.to_vec(),
298        };
299        let path = self.image_record(id);
300        let tmp = self.tmp_path("image");
301        let bytes = serde_json::to_vec_pretty(&rec).map_err(|e| Error::Invalid(e.to_string()))?;
302        fs::write(&tmp, bytes).ctx(|| format!("writing {}", tmp.display()))?;
303        fs::rename(&tmp, &path).ctx(|| format!("placing {}", path.display()))?;
304        Ok(dirs)
305    }
306
307    /// The layer directories of a recorded image, bottom first.
308    pub fn image_layers(&self, id: &Digest) -> Result<Vec<PathBuf>> {
309        Ok(self
310            .record(id)?
311            .layers
312            .iter()
313            .map(|l| self.layer_path(&l.diff_id))
314            .collect())
315    }
316
317    /// Forget image `id`; its layers stay until [`LayerStore::gc`].
318    pub fn remove_image(&self, id: &Digest) -> Result<()> {
319        let p = self.image_record(id);
320        fs::remove_file(&p).ctx(|| format!("removing {}", p.display()))
321    }
322
323    /// How many recorded images use the layer.
324    pub fn refcount(&self, diff_id: &Digest) -> Result<usize> {
325        Ok(self
326            .records()?
327            .iter()
328            .filter(|r| r.layers.iter().any(|l| &l.diff_id == diff_id))
329            .count())
330    }
331
332    /// Remove layer directories and blobs that no recorded image uses, and
333    /// leftovers in `tmp/`. Run it while no pull is in progress.
334    pub fn gc(&self) -> Result<GcStats> {
335        let recs = self.records()?;
336        let diff_ids: std::collections::HashSet<String> = recs
337            .iter()
338            .flat_map(|r| r.layers.iter().map(|l| l.diff_id.hex().to_string()))
339            .collect();
340        let blobs: std::collections::HashSet<String> = recs
341            .iter()
342            .flat_map(|r| r.layers.iter().map(|l| l.blob.hex().to_string()))
343            .collect();
344        let mut st = GcStats::default();
345        for (dir, keep, n) in [
346            ("layers", &diff_ids, &mut st.layers),
347            ("blobs/sha256", &blobs, &mut st.blobs),
348        ] {
349            let d = self.root.join(dir);
350            for e in fs::read_dir(&d)
351                .ctx(|| format!("listing {}", d.display()))?
352                .flatten()
353            {
354                let name = e.file_name().to_string_lossy().into_owned();
355                if !keep.contains(&name) {
356                    let p = e.path();
357                    let r = if p.is_dir() {
358                        fs::remove_dir_all(&p)
359                    } else {
360                        fs::remove_file(&p)
361                    };
362                    r.ctx(|| format!("removing {}", p.display()))?;
363                    *n += 1;
364                }
365            }
366        }
367        let t = self.root.join("tmp");
368        for e in fs::read_dir(&t)
369            .ctx(|| format!("listing {}", t.display()))?
370            .flatten()
371        {
372            let p = e.path();
373            let _ = if p.is_dir() {
374                fs::remove_dir_all(&p)
375            } else {
376                fs::remove_file(&p)
377            };
378        }
379        Ok(st)
380    }
381
382    /// Whether the store holds what `blob` is: the blob itself, or the layer
383    /// directory it unpacks to (its blob pruned by
384    /// [`LayerStore::prune_unpacked_blobs`]), as an image record names it. A
385    /// pull asks this to skip what it need not download.
386    pub fn holds_blob(&self, blob: &Digest) -> bool {
387        self.has_blob(blob)
388            || self.records().is_ok_and(|rs| {
389                rs.iter()
390                    .flat_map(|r| &r.layers)
391                    .any(|l| &l.blob == blob && self.has_layer(&l.diff_id))
392            })
393    }
394
395    /// Delete the blobs of layers that are unpacked (to save disk); a later
396    /// pull of the same layers needs neither the blob nor a download.
397    pub fn prune_unpacked_blobs(&self) -> Result<usize> {
398        let mut n = 0;
399        for r in self.records()? {
400            for l in r.layers {
401                if self.has_layer(&l.diff_id) && fs::remove_file(self.blob_path(&l.blob)).is_ok() {
402                    n += 1;
403                }
404            }
405        }
406        Ok(n)
407    }
408
409    fn image_record(&self, id: &Digest) -> PathBuf {
410        self.root.join("images").join(format!("{}.json", id.hex()))
411    }
412
413    fn record(&self, id: &Digest) -> Result<ImageRecord> {
414        let p = self.image_record(id);
415        let b = fs::read(&p).ctx(|| format!("reading {}", p.display()))?;
416        serde_json::from_slice(&b).map_err(|e| Error::Invalid(format!("{}: {e}", p.display())))
417    }
418
419    fn records(&self) -> Result<Vec<ImageRecord>> {
420        let d = self.root.join("images");
421        let mut out = Vec::new();
422        for e in fs::read_dir(&d)
423            .ctx(|| format!("listing {}", d.display()))?
424            .flatten()
425        {
426            if let Ok(b) = fs::read(e.path()) {
427                if let Ok(r) = serde_json::from_slice(&b) {
428                    out.push(r);
429                }
430            }
431        }
432        Ok(out)
433    }
434}