holger_handler_bundle_core/
lib.rs1use core::marker::PhantomData;
40
41use holger_plugin_abi::{
42 BlobStore, PackageHandler, WireArtifactEntry, WireArtifactId, WireHttpRequest,
43 WireHttpResponse, WireManifest, ABI_VERSION,
44};
45
46pub const BUNDLE_EXT: &str = ".znippy";
48
49pub const BUNDLE_CONTENT_TYPE: &str = "application/vnd.znippy.bundle";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct MemberDescription {
58 pub kind: String,
60 pub component: String,
62 pub encrypted: bool,
64}
65
66impl MemberDescription {
67 pub fn to_line(&self) -> String {
71 format!("kind\t{}\ncomponent\t{}\nencrypted\t{}", self.kind, self.component, self.encrypted)
72 }
73}
74
75pub trait BundleKind {
77 const HANDLER: &'static str;
79
80 const FORMAT: &'static str;
82
83 const WRITABLE: bool;
85
86 fn describe_member(path: &str) -> Option<MemberDescription>;
89
90 fn is_bundle_name(file_name: &str) -> bool;
93}
94
95pub struct BundleHandler<K: BundleKind>(PhantomData<K>);
97
98impl<K: BundleKind> BundleHandler<K> {
99 pub const fn new() -> Self {
100 BundleHandler(PhantomData)
101 }
102}
103
104impl<K: BundleKind> Default for BundleHandler<K> {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl<K: BundleKind> Clone for BundleHandler<K> {
111 fn clone(&self) -> Self {
112 Self::new()
113 }
114}
115
116pub fn store_key(id: &WireArtifactId) -> String {
124 format!("{}-{}{}", id.name, id.version, BUNDLE_EXT)
125}
126
127pub fn coordinate_from_key(key: &str) -> Option<WireArtifactId> {
134 let base = key.rsplit(['/', '\\']).next().unwrap_or(key);
135 let stem = base.strip_suffix(BUNDLE_EXT)?;
136 let (name, version) = stem.rsplit_once('-')?;
137 if name.is_empty() || version.is_empty() {
138 return None;
139 }
140 Some(WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() })
141}
142
143fn path_segments(suburl: &str) -> Vec<&str> {
149 let mut segs = suburl.split('/').filter(|s| !s.is_empty());
150 segs.next();
151 segs.collect()
152}
153
154fn id_of(name: &str, version: &str) -> WireArtifactId {
155 WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() }
156}
157
158impl<K: BundleKind> PackageHandler for BundleHandler<K> {
159 fn manifest(&self) -> WireManifest {
160 WireManifest {
161 abi_version: ABI_VERSION,
162 handler: K::HANDLER.to_string(),
163 format: K::FORMAT.to_string(),
164 writable: K::WRITABLE,
165 }
166 }
167
168 fn fetch(&self, store: &dyn BlobStore, id: &WireArtifactId) -> Result<Option<Vec<u8>>, String> {
169 store.get(&store_key(id))
170 }
171
172 fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8]) -> Result<(), String> {
173 if !K::WRITABLE {
174 return Err(format!("{}: repository is read-only", K::HANDLER));
175 }
176 if id.name.is_empty() || id.version.is_empty() {
180 return Err(format!(
181 "{}: refusing to store a bundle with an empty name or version \
182 (name={:?}, version={:?})",
183 K::HANDLER,
184 id.name,
185 id.version
186 ));
187 }
188 store.put(&store_key(id), data)
189 }
190
191 fn list(
192 &self,
193 store: &dyn BlobStore,
194 name_filter: Option<&str>,
195 limit: usize,
196 ) -> Result<Vec<WireArtifactEntry>, String> {
197 let mut out = Vec::new();
198 let mut rows = store.list("")?;
199 rows.sort_by(|a, b| a.0.cmp(&b.0));
202 for (key, size) in rows {
203 if out.len() >= limit {
204 break;
205 }
206 let base = key.rsplit(['/', '\\']).next().unwrap_or(&key);
207 if !K::is_bundle_name(base) {
208 continue;
209 }
210 let Some(id) = coordinate_from_key(&key) else {
211 continue;
212 };
213 if let Some(f) = name_filter {
214 if !id.name.contains(f) {
215 continue;
216 }
217 }
218 out.push(WireArtifactEntry {
219 id,
220 size_bytes: size.min(i64::MAX as u64) as i64,
223 content_type: BUNDLE_CONTENT_TYPE.to_string(),
224 });
225 }
226 Ok(out)
227 }
228
229 fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId> {
230 let segs = path_segments(suburl);
231 match segs.as_slice() {
232 [name, version] => Some(id_of(name, version)),
233 [name, version, "classify", ..] => Some(id_of(name, version)),
237 _ => None,
238 }
239 }
240
241 fn http(&self, store: &dyn BlobStore, req: &WireHttpRequest) -> Result<WireHttpResponse, String> {
242 let segs = path_segments(&req.suburl);
243
244 match (req.method.as_str(), segs.as_slice()) {
245 ("GET", []) => {
246 let mut names: Vec<String> = store
247 .list("")?
248 .into_iter()
249 .map(|(k, _)| k)
250 .filter(|k| K::is_bundle_name(k.rsplit(['/', '\\']).next().unwrap_or(k)))
251 .collect();
252 names.sort();
253 Ok(text(200, names.join("\n")))
254 }
255
256 ("GET", [name, version]) => {
257 let id = id_of(name, version);
258 match store.get(&store_key(&id))? {
259 Some(body) => Ok(WireHttpResponse {
260 status: 200,
261 headers: vec![
262 ("content-type".into(), BUNDLE_CONTENT_TYPE.into()),
263 ("content-length".into(), body.len().to_string()),
264 ],
265 body,
266 }),
267 None => Ok(text(404, format!("no such bundle: {}", store_key(&id)))),
268 }
269 }
270
271 ("GET", [_name, _version, "classify", rest @ ..]) => {
275 if rest.is_empty() {
276 return Ok(text(400, "classify needs a bundle-relative path"));
277 }
278 let member_path = rest.join("/");
279 match K::describe_member(&member_path) {
280 Some(d) => Ok(text(200, d.to_line())),
281 None => Ok(text(
282 404,
283 format!("{}: '{member_path}' is not a recognised bundle member", K::HANDLER),
284 )),
285 }
286 }
287
288 ("PUT", [name, version]) => {
289 let id = id_of(name, version);
290 self.put(store, &id, &req.body)?;
291 Ok(text(201, format!("stored {}", store_key(&id))))
292 }
293
294 (m, _) => Ok(text(405, format!("{}: {m} {} is not a bundle route", K::HANDLER, req.suburl))),
295 }
296 }
297}
298
299fn text(status: u16, body: impl Into<String>) -> WireHttpResponse {
300 let body = body.into().into_bytes();
301 WireHttpResponse {
302 status,
303 headers: vec![
304 ("content-type".into(), "text/plain; charset=utf-8".into()),
305 ("content-length".into(), body.len().to_string()),
306 ],
307 body,
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn a_hyphenated_bundle_name_keeps_its_hyphens() {
317 let id = coordinate_from_key("rust-dev-rhel8-1.97.1.znippy").expect("parsed");
318 assert_eq!(id.name, "rust-dev-rhel8", "split at the FIRST hyphen instead of the last");
319 assert_eq!(id.version, "1.97.1");
320 }
321
322 #[test]
323 fn store_key_and_coordinate_are_inverses() {
324 for (name, version) in
325 [("tillsynia", "20260706"), ("rust-dev-rhel8", "1.97.1"), ("a", "0")]
326 {
327 let id = id_of(name, version);
328 let back = coordinate_from_key(&store_key(&id)).expect("round trip");
329 assert_eq!(back, id, "store_key/coordinate_from_key are not inverses for {name}");
330 }
331 }
332
333 #[test]
334 fn a_key_that_is_not_a_bundle_yields_no_coordinate() {
335 assert_eq!(coordinate_from_key("README.md"), None);
336 assert_eq!(coordinate_from_key("noversion.znippy"), None);
337 assert_eq!(coordinate_from_key("-1.0.znippy"), None, "empty name accepted");
338 assert_eq!(coordinate_from_key("x-.znippy"), None, "empty version accepted");
339 }
340
341 #[test]
342 fn the_repo_segment_is_never_part_of_a_coordinate() {
343 assert_eq!(path_segments("/bundles/tillsynia/20260706"), vec!["tillsynia", "20260706"]);
344 assert_eq!(path_segments("/bundles/"), Vec::<&str>::new());
345 }
346}