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