Skip to main content

blvm_sdk/composition/
registry.rs

1//! Module Registry
2//!
3//! High-level module registry API for discovering, installing, updating,
4//! and removing modules. Wraps blvm-node module registry functionality.
5//!
6//! **Trust:** Use HTTPS registry URLs and a host you trust. The client enforces timeouts and
7//! response size limits; archive extraction uses safe path semantics under a destination directory.
8//! HTTPS pinning and organization allowlists are operator policy, not enforced here.
9
10use 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/// Limits for DoS protection when unpacking registry `.tar.gz` archives.
53#[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/// Extract a gzip-compressed tar into `dest_dir` without invoking the system `tar` binary.
59/// Uses [`tar::Entry::unpack_in`] so paths cannot escape `dest_dir` (rejects `..` and absolute paths).
60#[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, // "registry" | "git"
99    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
143/// Module registry for managing module lifecycle
144pub struct ModuleRegistry {
145    /// Base directory for modules
146    modules_dir: PathBuf,
147    /// Discovered modules cache (manifest index)
148    discovered: Vec<ModuleInfo>,
149    /// Cached discovery snapshot for dependency resolution (REV-S-03/04)
150    discovered_raw: Vec<RefDiscoveredModule>,
151}
152
153impl ModuleRegistry {
154    /// Create a new module registry
155    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    /// Discover available modules in the modules directory
164    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    /// Get module by name and optional version
177    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    /// Install module from source
195    pub fn install_module(&mut self, source: ModuleSource) -> Result<ModuleInfo> {
196        match source {
197            ModuleSource::Path(path) => {
198                // Validate path exists
199                if !path.exists() {
200                    return Err(CompositionError::InstallationFailed(format!(
201                        "Module path does not exist: {path:?}"
202                    )));
203                }
204
205                // Validate the module at the source path, then refresh the registry index.
206                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    /// Update module to new version (re-pull from git if from git, else re-download from registry)
229    pub fn update_module(&mut self, name: &str, new_version: Option<&str>) -> Result<ModuleInfo> {
230        let _ = new_version; // used only in feature-gated branches (git / registry)
231        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        // Fallback: try git if .git exists (legacy installs without .blvm-source.json)
270        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    /// Remove module from disk.
439    /// Callers with a running node should stop the module first via `ModuleLifecycle::stop_module`.
440    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        // Refresh discovered modules
448        self.discover_modules()?;
449
450        Ok(())
451    }
452
453    /// List all installed modules
454    pub fn list_modules(&self) -> Vec<ModuleInfo> {
455        self.discovered.clone()
456    }
457
458    /// Resolve dependencies for a set of modules using the cached discovery snapshot.
459    ///
460    /// Call [`discover_modules`](Self::discover_modules) after install/remove so the cache is current.
461    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        // Build result with resolved modules
483        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}