blvm_sdk/composition/
registry.rs1use crate::composition::types::*;
11use blvm_node::module::registry::{
12 ModuleDependencies as RefModuleDependencies, ModuleDiscovery as RefModuleDiscovery,
13};
14use blvm_node::module::traits::ModuleError as RefModuleError;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const SOURCE_FILE: &str = ".blvm-source.json";
19
20#[cfg(feature = "registry")]
21const REGISTRY_HTTP_TIMEOUT_SECS: u64 = 120;
22#[cfg(feature = "registry")]
23const REGISTRY_INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
24#[cfg(feature = "registry")]
25const REGISTRY_DOWNLOAD_MAX_BYTES: usize = 64 * 1024 * 1024;
26
27#[cfg(feature = "registry")]
28fn registry_http_client() -> Result<reqwest::blocking::Client> {
29 reqwest::blocking::Client::builder()
30 .timeout(std::time::Duration::from_secs(REGISTRY_HTTP_TIMEOUT_SECS))
31 .connect_timeout(std::time::Duration::from_secs(30))
32 .build()
33 .map_err(|e| {
34 CompositionError::InstallationFailed(format!("Failed to build HTTP client: {e}"))
35 })
36}
37
38#[cfg(feature = "registry")]
39fn enforce_max_response(label: &str, bytes: &[u8], max: usize) -> Result<()> {
40 if bytes.len() > max {
41 return Err(CompositionError::InstallationFailed(format!(
42 "{} response too large: {} bytes (max {})",
43 label,
44 bytes.len(),
45 max
46 )));
47 }
48 Ok(())
49}
50
51#[cfg(feature = "registry")]
53const MAX_TAR_ENTRIES: usize = 100_000;
54#[cfg(feature = "registry")]
55const MAX_TAR_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
56
57#[cfg(feature = "registry")]
60fn extract_tar_gz_safe(archive_path: &Path, dest_dir: &Path) -> Result<()> {
61 use flate2::read::GzDecoder;
62 use std::fs::File;
63 use tar::Archive;
64
65 let file = File::open(archive_path)
66 .map_err(|e| CompositionError::InstallationFailed(format!("Open module archive: {e}")))?;
67 let dec = GzDecoder::new(file);
68 let mut archive = Archive::new(dec);
69 let mut count = 0usize;
70 for entry in archive
71 .entries()
72 .map_err(|e| CompositionError::InstallationFailed(format!("Read tar archive: {e}")))?
73 {
74 let mut entry =
75 entry.map_err(|e| CompositionError::InstallationFailed(format!("Tar entry: {e}")))?;
76 count += 1;
77 if count > MAX_TAR_ENTRIES {
78 return Err(CompositionError::InstallationFailed(format!(
79 "Too many files in module archive (max {MAX_TAR_ENTRIES})"
80 )));
81 }
82 let size = entry.size();
83 if size > MAX_TAR_ENTRY_BYTES {
84 return Err(CompositionError::InstallationFailed(format!(
85 "Module archive member too large: {size} bytes (max {MAX_TAR_ENTRY_BYTES})"
86 )));
87 }
88 entry
89 .unpack_in(dest_dir)
90 .map_err(|e| CompositionError::InstallationFailed(format!("Extract failed: {e}")))?;
91 }
92 Ok(())
93}
94
95#[derive(serde::Serialize, serde::Deserialize)]
96struct ModuleSourceFile {
97 source: String, url: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 tag: Option<String>,
101}
102
103#[cfg(feature = "registry")]
104fn write_source_file(dir: &Path, source: &str, url: &str) -> Result<()> {
105 let path = dir.join(SOURCE_FILE);
106 let content = ModuleSourceFile {
107 source: source.to_string(),
108 url: url.to_string(),
109 tag: None,
110 };
111 let json = serde_json::to_string_pretty(&content)
112 .map_err(|e| CompositionError::SerializationError(e.to_string()))?;
113 fs::write(&path, json).map_err(CompositionError::IoError)?;
114 Ok(())
115}
116
117#[cfg(feature = "git")]
118fn write_source_file_git(dir: &Path, url: &str, tag: Option<&str>) -> Result<()> {
119 let path = dir.join(SOURCE_FILE);
120 let content = ModuleSourceFile {
121 source: "git".to_string(),
122 url: url.to_string(),
123 tag: tag.map(String::from),
124 };
125 let json = serde_json::to_string_pretty(&content)
126 .map_err(|e| CompositionError::SerializationError(e.to_string()))?;
127 fs::write(&path, json).map_err(CompositionError::IoError)?;
128 Ok(())
129}
130
131fn read_source_file(dir: &Path) -> Result<Option<ModuleSourceFile>> {
132 let path = dir.join(SOURCE_FILE);
133 if !path.exists() {
134 return Ok(None);
135 }
136 let content = fs::read_to_string(&path).map_err(CompositionError::IoError)?;
137 let parsed: ModuleSourceFile = serde_json::from_str(&content)
138 .map_err(|e| CompositionError::SerializationError(e.to_string()))?;
139 Ok(Some(parsed))
140}
141
142pub struct ModuleRegistry {
144 modules_dir: PathBuf,
146 discovered: Vec<ModuleInfo>,
148}
149
150impl ModuleRegistry {
151 pub fn new<P: AsRef<Path>>(modules_dir: P) -> Self {
153 Self {
154 modules_dir: modules_dir.as_ref().to_path_buf(),
155 discovered: Vec::new(),
156 }
157 }
158
159 pub fn discover_modules(&mut self) -> Result<Vec<ModuleInfo>> {
161 let discovery = RefModuleDiscovery::new(&self.modules_dir);
162 let discovered = discovery
163 .discover_modules()
164 .map_err(|e: RefModuleError| CompositionError::from(e))?;
165
166 self.discovered = discovered.iter().map(ModuleInfo::from).collect();
167
168 Ok(self.discovered.clone())
169 }
170
171 pub fn get_module(&self, name: &str, version: Option<&str>) -> Result<ModuleInfo> {
173 let module = self
174 .discovered
175 .iter()
176 .find(|m| m.name == name && version.is_none_or(|v| m.version == v))
177 .ok_or_else(|| {
178 let msg = if let Some(v) = version {
179 format!("Module {name} version {v} not found")
180 } else {
181 format!("Module {name} not found")
182 };
183 CompositionError::ModuleNotFound(msg)
184 })?;
185
186 Ok(module.clone())
187 }
188
189 pub fn install_module(&mut self, source: ModuleSource) -> Result<ModuleInfo> {
191 match source {
192 ModuleSource::Path(path) => {
193 if !path.exists() {
195 return Err(CompositionError::InstallationFailed(format!(
196 "Module path does not exist: {path:?}"
197 )));
198 }
199
200 let discovery = RefModuleDiscovery::new(&path);
203 let discovered = discovery
204 .discover_modules()
205 .map_err(CompositionError::from)?;
206
207 if discovered.is_empty() {
208 return Err(CompositionError::InstallationFailed(
209 "No module found at path".to_string(),
210 ));
211 }
212
213 self.discover_modules()?;
215
216 Ok(ModuleInfo::from(&discovered[0]))
217 }
218 ModuleSource::Registry { url, name } => {
219 self.install_from_registry(&url, name.as_deref())
220 }
221 ModuleSource::Git { url, tag } => self.install_from_git(&url, tag.as_deref()),
222 }
223 }
224
225 pub fn update_module(&mut self, name: &str, new_version: Option<&str>) -> Result<ModuleInfo> {
227 let _ = new_version; let current = self.get_module(name, None)?;
229 let dir = current.directory.as_ref().ok_or_else(|| {
230 CompositionError::InstallationFailed("Module has no directory".to_string())
231 })?;
232
233 if let Some(source_file) = read_source_file(dir)? {
234 match source_file.source.as_str() {
235 "git" => {
236 #[cfg(feature = "git")]
237 {
238 self.update_module_from_git(name, new_version)?;
239 return self.get_module(name, new_version);
240 }
241 #[cfg(not(feature = "git"))]
242 {
243 return Err(CompositionError::InstallationFailed(
244 "Module update from git requires 'git' feature".to_string(),
245 ));
246 }
247 }
248 "registry" => {
249 #[cfg(feature = "registry")]
250 {
251 self.remove_module(name)?;
252 self.install_from_registry(&source_file.url, Some(name))?;
253 return self.get_module(name, new_version);
254 }
255 #[cfg(not(feature = "registry"))]
256 {
257 return Err(CompositionError::InstallationFailed(
258 "Module update from registry requires 'registry' feature".to_string(),
259 ));
260 }
261 }
262 _ => {}
263 }
264 }
265
266 let git_dir = dir.join(".git");
268 if git_dir.exists() {
269 #[cfg(feature = "git")]
270 {
271 self.update_module_from_git(name, new_version)?;
272 return self.get_module(name, new_version);
273 }
274 }
275
276 Err(CompositionError::InstallationFailed(
277 "Module has no install source (.blvm-source.json). Reinstall from registry or git."
278 .to_string(),
279 ))
280 }
281
282 #[cfg(feature = "registry")]
283 fn install_from_registry(&mut self, url: &str, name: Option<&str>) -> Result<ModuleInfo> {
284 let client = registry_http_client()?;
285 let index_resp = client.get(url).send().map_err(|e| {
286 CompositionError::InstallationFailed(format!("Registry fetch failed: {e}"))
287 })?;
288 let index_bytes = index_resp.bytes().map_err(|e| {
289 CompositionError::InstallationFailed(format!("Registry read failed: {e}"))
290 })?;
291 enforce_max_response("Registry index", &index_bytes, REGISTRY_INDEX_MAX_BYTES)?;
292 let index: serde_json::Value = serde_json::from_slice(&index_bytes).map_err(|e| {
293 CompositionError::InstallationFailed(format!("Registry JSON parse failed: {e}"))
294 })?;
295
296 let modules = index
297 .get("modules")
298 .and_then(|m| m.as_array())
299 .ok_or_else(|| {
300 CompositionError::InstallationFailed("Registry missing 'modules' array".to_string())
301 })?;
302
303 if modules.is_empty() {
304 return Err(CompositionError::InstallationFailed(
305 "Registry has no modules".to_string(),
306 ));
307 }
308
309 let selected = if let Some(n) = name {
310 modules
311 .iter()
312 .find(|m| m.get("name").and_then(|v| v.as_str()) == Some(n))
313 .ok_or_else(|| {
314 CompositionError::InstallationFailed(format!(
315 "Module '{n}' not found in registry"
316 ))
317 })?
318 } else {
319 &modules[0]
320 };
321
322 let first = selected;
323 let name = first.get("name").and_then(|n| n.as_str()).ok_or_else(|| {
324 CompositionError::InstallationFailed("Module missing 'name'".to_string())
325 })?;
326 let download_url = first
327 .get("download_url")
328 .or_else(|| first.get("url"))
329 .and_then(|u| u.as_str())
330 .ok_or_else(|| {
331 CompositionError::InstallationFailed("Module missing download_url".to_string())
332 })?;
333
334 let dl_resp = client
335 .get(download_url)
336 .send()
337 .map_err(|e| CompositionError::InstallationFailed(format!("Download failed: {e}")))?;
338 let bytes = dl_resp.bytes().map_err(|e| {
339 CompositionError::InstallationFailed(format!("Download read failed: {e}"))
340 })?;
341 enforce_max_response("Module archive", &bytes, REGISTRY_DOWNLOAD_MAX_BYTES)?;
342
343 let dest_dir = self.modules_dir.join(name);
344 fs::create_dir_all(&dest_dir)?;
345 let archive_path = dest_dir.join("module.tar.gz");
346 fs::write(&archive_path, &bytes).map_err(CompositionError::IoError)?;
347
348 extract_tar_gz_safe(&archive_path, &dest_dir)?;
349 fs::remove_file(&archive_path).ok();
350
351 self.discover_modules()?;
352 let info = self.get_module(name, None)?;
353 let fallback_dir = self.modules_dir.join(name);
354 let dir = info.directory.as_ref().unwrap_or(&fallback_dir);
355 write_source_file(dir, "registry", url)?;
356 Ok(info)
357 }
358
359 #[cfg(not(feature = "registry"))]
360 fn install_from_registry(&mut self, _url: &str, _name: Option<&str>) -> Result<ModuleInfo> {
361 Err(CompositionError::InstallationFailed(
362 "Registry installation requires 'registry' feature (reqwest)".to_string(),
363 ))
364 }
365
366 #[cfg(feature = "git")]
367 fn install_from_git(&mut self, url: &str, tag: Option<&str>) -> Result<ModuleInfo> {
368 let repo_name = url
369 .split('/')
370 .next_back()
371 .unwrap_or("module")
372 .trim_end_matches(".git");
373 let dest_dir = self.modules_dir.join(repo_name);
374
375 if dest_dir.exists() {
376 fs::remove_dir_all(&dest_dir).map_err(CompositionError::IoError)?;
377 }
378
379 let mut builder = git2::build::RepoBuilder::new();
380 if let Some(t) = tag {
381 builder.branch(t);
382 }
383 builder
384 .clone(url, &dest_dir)
385 .map_err(|e| CompositionError::InstallationFailed(format!("Git clone failed: {e}")))?;
386
387 write_source_file_git(&dest_dir, url, tag)?;
388 self.discover_modules()?;
389 self.get_module(repo_name, None)
390 }
391
392 #[cfg(not(feature = "git"))]
393 fn install_from_git(&mut self, _url: &str, _tag: Option<&str>) -> Result<ModuleInfo> {
394 Err(CompositionError::InstallationFailed(
395 "Git installation requires 'git' feature (git2)".to_string(),
396 ))
397 }
398
399 #[cfg(feature = "git")]
400 fn update_module_from_git(&mut self, name: &str, _new_version: Option<&str>) -> Result<()> {
401 let current = self.get_module(name, None)?;
402 let dir = current.directory.as_ref().ok_or_else(|| {
403 CompositionError::InstallationFailed("Module has no directory".to_string())
404 })?;
405
406 let repo = git2::Repository::open(dir)
407 .map_err(|e| CompositionError::InstallationFailed(format!("Git open failed: {e}")))?;
408 let mut remote = repo.find_remote("origin").map_err(|e| {
409 CompositionError::InstallationFailed(format!("Git remote origin not found: {e}"))
410 })?;
411 let refspecs: &[&str] = &[];
412 remote
413 .fetch(refspecs, None, None)
414 .map_err(|e| CompositionError::InstallationFailed(format!("Git fetch failed: {e}")))?;
415
416 let fetch_head = repo
417 .find_reference("FETCH_HEAD")
418 .map_err(|e| CompositionError::InstallationFailed(format!("FETCH_HEAD failed: {e}")))?;
419 let oid = fetch_head.target().ok_or_else(|| {
420 CompositionError::InstallationFailed("Invalid FETCH_HEAD".to_string())
421 })?;
422 let obj = repo.find_object(oid, None).map_err(|e| {
423 CompositionError::InstallationFailed(format!("Find object failed: {e}"))
424 })?;
425 repo.checkout_tree(&obj, None).map_err(|e| {
426 CompositionError::InstallationFailed(format!("Checkout tree failed: {e}"))
427 })?;
428 repo.set_head_detached(oid)
429 .map_err(|e| CompositionError::InstallationFailed(format!("Checkout failed: {e}")))?;
430
431 self.discover_modules()?;
432 Ok(())
433 }
434
435 pub fn remove_module(&mut self, name: &str) -> Result<()> {
438 let module = self.get_module(name, None)?;
439
440 if let Some(dir) = &module.directory {
441 std::fs::remove_dir_all(dir).map_err(CompositionError::IoError)?;
442 }
443
444 self.discover_modules()?;
446
447 Ok(())
448 }
449
450 pub fn list_modules(&self) -> Vec<ModuleInfo> {
452 self.discovered.clone()
453 }
454
455 pub fn resolve_dependencies(&self, module_names: &[String]) -> Result<Vec<ModuleInfo>> {
457 let discovery = RefModuleDiscovery::new(&self.modules_dir);
460 let all_discovered = discovery
461 .discover_modules()
462 .map_err(CompositionError::from)?;
463
464 let requested: Vec<_> = all_discovered
466 .iter()
467 .filter(|d| module_names.contains(&d.manifest.name))
468 .cloned()
469 .collect();
470
471 let resolution =
472 RefModuleDependencies::resolve(&requested).map_err(CompositionError::from)?;
473
474 let mut resolved = Vec::new();
476 for name in &resolution.load_order {
477 let module = self.get_module(name, None)?;
478 resolved.push(module);
479 }
480
481 Ok(resolved)
482 }
483}