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#[derive(Clone, Copy, Debug)]
15pub enum BucketKind {
16 Images,
17 Content,
18 Leases,
19 Snapshots,
20}
21
22#[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 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 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
299pub(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}