containerd_store/
store.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4use bolt_lite::{Bolt, Bucket, Tx};
5
6use crate::types::{Descriptor, ImageEntry, ResolvedImage, Result, StoreError};
7
8#[cfg(feature = "bucket-logging")]
9static BUCKET_MATCH_LOGGER: OnceLock<Box<dyn Fn(BucketMatch) + Send + Sync>> = OnceLock::new();
10
11static SIZE_WARN_ONCE: OnceLock<()> = OnceLock::new();
12
13/// Category of bucket being resolved so layout differences can be traced.
14#[derive(Clone, Copy, Debug)]
15pub enum BucketKind {
16    Images,
17    Content,
18    Leases,
19    Snapshots,
20}
21
22/// Information sent to the optional bucket-match logger.
23#[cfg(feature = "bucket-logging")]
24#[derive(Clone, Debug)]
25pub struct BucketMatch {
26    pub kind: BucketKind,
27    pub path: Vec<String>,
28}
29
30const META_DIR: &str = "io.containerd.metadata.v1.bolt";
31const META_DB: &str = "meta.db";
32
33pub struct ContainerdStore {
34    root: PathBuf,
35    namespace: String,
36    db_path: PathBuf,
37}
38
39#[cfg(feature = "bucket-logging")]
40pub fn set_bucket_match_logger<F>(logger: F) -> Result<(), &'static str>
41where
42    F: Fn(BucketMatch) + Send + Sync + 'static,
43{
44    BUCKET_MATCH_LOGGER
45        .set(Box::new(logger))
46        .map_err(|_| "bucket match logger already set")
47}
48
49impl ContainerdStore {
50    pub fn open<P: AsRef<Path>>(root: P, namespace: &str) -> Result<Self> {
51        let root = root.as_ref().to_path_buf();
52        let db_path = root.join(META_DIR).join(META_DB);
53        if !db_path.exists() {
54            return Err(StoreError::DbOpen(format!(
55                "metadata DB not found at {}",
56                db_path.display()
57            )));
58        }
59        Bolt::open_ro(&db_path).map_err(|e| {
60            StoreError::DbOpen(format!(
61                "failed to open metadata DB at {}: {}",
62                db_path.display(),
63                e
64            ))
65        })?;
66        Ok(Self {
67            root,
68            namespace: namespace.to_string(),
69            db_path,
70        })
71    }
72
73    pub fn content_root(&self) -> PathBuf {
74        self.root
75            .join("io.containerd.content.v1.content")
76            .join("blobs")
77    }
78
79    pub fn meta_db_path(&self) -> PathBuf {
80        self.db_path.clone()
81    }
82
83    pub fn root_path(&self) -> PathBuf {
84        self.root.clone()
85    }
86
87    pub fn list_images(&self) -> Result<Vec<ImageEntry>> {
88        let namespace = self.namespace.clone();
89        let mut images = Vec::new();
90
91        let db = Bolt::open_ro(&self.db_path).map_err(|e| StoreError::Db(format!("{}", e)))?;
92        let tx = db.begin().map_err(|e| StoreError::Db(format!("{}", e)))?;
93
94        let images_bucket = find_images_bucket(&tx, &namespace)
95            .ok_or_else(|| StoreError::ImagesBucketMissing(namespace.clone()))?;
96
97        for (name_bytes, bucket) in images_bucket.iter_buckets() {
98            let name = std::str::from_utf8(&name_bytes)
99                .map_err(|e| StoreError::Utf8(e.to_string()))?
100                .to_string();
101            if let Some(entry) = parse_image_bucket(&bucket, &name)? {
102                images.push(entry);
103            }
104        }
105
106        Ok(images)
107    }
108
109    pub fn resolve_image(&self, name: &str) -> Result<ResolvedImage> {
110        let content_root = self.content_root();
111        let namespace = self.namespace.clone();
112
113        let db = Bolt::open_ro(&self.db_path).map_err(|e| StoreError::Db(format!("{}", e)))?;
114        let tx = db.begin().map_err(|e| StoreError::Db(format!("{}", e)))?;
115
116        let images_bucket = find_images_bucket(&tx, &namespace)
117            .ok_or_else(|| StoreError::ImagesBucketMissing(namespace.clone()))?;
118
119        let image_bucket = images_bucket
120            .bucket(name.as_bytes())
121            .ok_or_else(|| StoreError::ImageNotFound(name.to_string()))?;
122        let entry = parse_image_bucket(&image_bucket, name)?
123            .ok_or_else(|| StoreError::DescriptorMissing(name.to_string()))?;
124
125        let manifest_digest = &entry.target.digest;
126        let digest_ref = crate::types::DigestRef::parse(manifest_digest)?;
127        let manifest_path = digest_ref.path_under(&content_root);
128        Ok(ResolvedImage {
129            entry,
130            manifest_path,
131        })
132    }
133}
134
135fn parse_image_bucket(bucket: &Bucket<'_>, name: &str) -> Result<Option<ImageEntry>> {
136    // Prefer nested "target" bucket; fallback to current bucket keys.
137    if let Some(target) = bucket.bucket(b"target") {
138        if let Some(desc) = read_descriptor_bucket(&target)? {
139            let created_at = read_str_entry(bucket, b"createdat");
140            let updated_at = read_str_entry(bucket, b"updatedat");
141            return Ok(Some(ImageEntry {
142                name: name.to_string(),
143                target: desc,
144                created_at,
145                updated_at,
146            }));
147        }
148    }
149
150    if let Some(desc) = read_descriptor_bucket(bucket)? {
151        let created_at = read_str_entry(bucket, b"createdat");
152        let updated_at = read_str_entry(bucket, b"updatedat");
153        return Ok(Some(ImageEntry {
154            name: name.to_string(),
155            target: desc,
156            created_at,
157            updated_at,
158        }));
159    }
160
161    Ok(None)
162}
163
164fn read_descriptor_bucket(bucket: &Bucket<'_>) -> Result<Option<Descriptor>> {
165    let digest = read_str_entry(bucket, b"digest");
166    let media_type = read_str_entry(bucket, b"mediatype");
167    let size = parse_size(bucket.get(b"size"));
168
169    if let (Some(digest), Some(media_type)) = (digest, media_type) {
170        return Ok(Some(Descriptor {
171            media_type,
172            digest,
173            size,
174        }));
175    }
176
177    // Try entries of the bucket (not nested) as JSON
178    if let Some(raw) = bucket.get(b"target") {
179        if let Ok(json_desc) = serde_json::from_slice::<self::json_models::Target>(&raw) {
180            let size = json_desc.size.unwrap_or(0);
181            return Ok(Some(Descriptor {
182                media_type: json_desc.media_type,
183                digest: json_desc.digest,
184                size,
185            }));
186        }
187    }
188
189    Ok(None)
190}
191
192fn read_str_entry(bucket: &Bucket<'_>, key: &[u8]) -> Option<String> {
193    bucket.get(key).and_then(|v| String::from_utf8(v).ok())
194}
195
196fn parse_size(raw: Option<Vec<u8>>) -> i64 {
197    match raw {
198        Some(bytes) if bytes.len() == 8 => {
199            let mut buf = [0u8; 8];
200            buf.copy_from_slice(&bytes);
201            i64::from_le_bytes(buf)
202        }
203        Some(bytes) => {
204            if let Some(parsed) = std::str::from_utf8(&bytes)
205                .ok()
206                .and_then(|s| s.parse::<i64>().ok())
207            {
208                parsed
209            } else {
210                warn_size_default();
211                0
212            }
213        }
214        None => {
215            warn_size_default();
216            0
217        }
218    }
219}
220
221fn warn_size_default() {
222    SIZE_WARN_ONCE.get_or_init(|| {
223        eprintln!("containerd-store: missing or invalid descriptor size; defaulting to 0");
224    });
225}
226
227fn find_bucket_for<'a>(
228    tx: &'a Tx<'a>,
229    namespace: &str,
230    leaf: &[u8],
231    kind: BucketKind,
232) -> Option<Bucket<'a>> {
233    let ns = namespace.as_bytes();
234    for path in candidate_bucket_paths(ns, leaf) {
235        let bucket = if path.len() == 1 {
236            tx.bucket(path[0])
237        } else {
238            tx.bucket_path(&path)
239        };
240        if let Some(b) = bucket {
241            log_bucket_match(kind, &path);
242            return Some(b);
243        }
244    }
245    None
246}
247
248fn candidate_bucket_paths<'a>(namespace: &'a [u8], leaf: &'a [u8]) -> Vec<Vec<&'a [u8]>> {
249    vec![
250        vec![b"v1".as_ref(), namespace, leaf],
251        vec![
252            b"metadata".as_ref(),
253            b"namespaces".as_ref(),
254            namespace,
255            leaf,
256        ],
257        vec![b"metadata".as_ref(), leaf],
258        vec![b"v1".as_ref(), leaf],
259        vec![leaf],
260    ]
261}
262
263fn find_images_bucket<'a>(tx: &'a Tx<'a>, namespace: &str) -> Option<Bucket<'a>> {
264    find_bucket_for(tx, namespace, b"images", BucketKind::Images)
265}
266
267#[allow(dead_code)]
268fn find_content_bucket<'a>(tx: &'a Tx<'a>, namespace: &str) -> Option<Bucket<'a>> {
269    find_bucket_for(tx, namespace, b"content", BucketKind::Content)
270}
271
272#[allow(dead_code)]
273fn find_leases_bucket<'a>(tx: &'a Tx<'a>, namespace: &str) -> Option<Bucket<'a>> {
274    find_bucket_for(tx, namespace, b"leases", BucketKind::Leases)
275}
276
277#[allow(dead_code)]
278fn find_snapshots_bucket<'a>(tx: &'a Tx<'a>, namespace: &str) -> Option<Bucket<'a>> {
279    find_bucket_for(tx, namespace, b"snapshots", BucketKind::Snapshots)
280}
281
282#[cfg(feature = "bucket-logging")]
283fn log_bucket_match(kind: BucketKind, path: &[&[u8]]) {
284    if let Some(logger) = BUCKET_MATCH_LOGGER.get() {
285        let rendered = path
286            .iter()
287            .map(|segment| String::from_utf8_lossy(segment).to_string())
288            .collect();
289        logger(BucketMatch {
290            kind,
291            path: rendered,
292        });
293    }
294}
295
296#[cfg(not(feature = "bucket-logging"))]
297fn log_bucket_match(_kind: BucketKind, _path: &[&[u8]]) {}
298
299// Lightweight JSON models for fallback parsing when bucket stores JSON blobs.
300pub(crate) mod json_models {
301    use serde::Deserialize;
302
303    #[derive(Debug, Deserialize)]
304    pub struct Target {
305        #[serde(rename = "mediaType")]
306        pub media_type: String,
307        pub digest: String,
308        pub size: Option<i64>,
309    }
310}