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    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/// Limits for DoS protection when unpacking registry `.tar.gz` archives.
52#[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/// Extract a gzip-compressed tar into `dest_dir` without invoking the system `tar` binary.
58/// Uses [`tar::Entry::unpack_in`] so paths cannot escape `dest_dir` (rejects `..` and absolute paths).
59#[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, // "registry" | "git"
98    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
142/// Module registry for managing module lifecycle
143pub struct ModuleRegistry {
144    /// Base directory for modules
145    modules_dir: PathBuf,
146    /// Discovered modules cache
147    discovered: Vec<ModuleInfo>,
148}
149
150impl ModuleRegistry {
151    /// Create a new module registry
152    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    /// Discover available modules in the modules directory
160    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    /// Get module by name and optional version
172    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    /// Install module from source
190    pub fn install_module(&mut self, source: ModuleSource) -> Result<ModuleInfo> {
191        match source {
192            ModuleSource::Path(path) => {
193                // Validate path exists
194                if !path.exists() {
195                    return Err(CompositionError::InstallationFailed(format!(
196                        "Module path does not exist: {path:?}"
197                    )));
198                }
199
200                // For now, we'll just discover from the path
201                // In a full implementation, this would copy/install the module
202                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                // Refresh discovered modules
214                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    /// Update module to new version (re-pull from git if from git, else re-download from registry)
226    pub fn update_module(&mut self, name: &str, new_version: Option<&str>) -> Result<ModuleInfo> {
227        let _ = new_version; // used only in feature-gated branches (git / registry)
228        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        // Fallback: try git if .git exists (legacy installs without .blvm-source.json)
267        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    /// Remove module from disk.
436    /// Callers with a running node should stop the module first via `ModuleLifecycle::stop_module`.
437    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        // Refresh discovered modules
445        self.discover_modules()?;
446
447        Ok(())
448    }
449
450    /// List all installed modules
451    pub fn list_modules(&self) -> Vec<ModuleInfo> {
452        self.discovered.clone()
453    }
454
455    /// Resolve dependencies for a set of modules
456    pub fn resolve_dependencies(&self, module_names: &[String]) -> Result<Vec<ModuleInfo>> {
457        // First, we need to get the actual RefDiscoveredModule objects
458        // We'll need to re-discover or cache them. For now, let's re-discover.
459        let discovery = RefModuleDiscovery::new(&self.modules_dir);
460        let all_discovered = discovery
461            .discover_modules()
462            .map_err(CompositionError::from)?;
463
464        // Filter to only requested modules and convert to owned values
465        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        // Build result with resolved modules
475        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}