Skip to main content

cfgd_core/modules/
loader.rs

1//! Module loading and dependency resolution.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::path::Path;
5
6use crate::PathDisplayExt;
7use crate::config::parse_module;
8use crate::errors::{ConfigError, ModuleError, Result};
9
10use super::LoadedModule;
11
12// ---------------------------------------------------------------------------
13// Module loading
14// ---------------------------------------------------------------------------
15
16/// Cap on a single `module.yaml` to prevent memory exhaustion. Applies to both
17/// `load_module` and the inner read in `load_modules`.
18const MAX_MODULE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
19
20/// Read a `module.yaml` after enforcing [`MAX_MODULE_SIZE`].
21fn read_module_yaml_capped(module_yaml: &Path) -> Result<String> {
22    if let Ok(meta) = std::fs::metadata(module_yaml)
23        && meta.len() > MAX_MODULE_SIZE
24    {
25        return Err(ModuleError::InvalidSpec {
26            name: module_yaml.display_posix(),
27            message: format!(
28                "module file too large ({} bytes, max {})",
29                meta.len(),
30                MAX_MODULE_SIZE
31            ),
32        }
33        .into());
34    }
35
36    std::fs::read_to_string(module_yaml).map_err(|e| {
37        ConfigError::Invalid {
38            message: format!("cannot read module file {}: {e}", module_yaml.posix()),
39        }
40        .into()
41    })
42}
43
44/// Load all modules from the `modules/` directory under the given config dir.
45/// Returns a map of module name → LoadedModule.
46pub fn load_modules(config_dir: &Path) -> Result<HashMap<String, LoadedModule>> {
47    let modules_dir = config_dir.join("modules");
48    if !modules_dir.is_dir() {
49        return Ok(HashMap::new());
50    }
51
52    let mut modules = HashMap::new();
53    let entries = std::fs::read_dir(&modules_dir).map_err(|e| ConfigError::Invalid {
54        message: format!("cannot read modules directory {}: {e}", modules_dir.posix()),
55    })?;
56
57    for entry in entries {
58        let entry = entry.map_err(|e| ConfigError::Invalid {
59            message: format!("cannot read modules directory entry: {e}"),
60        })?;
61        let path = entry.path();
62        if !path.is_dir() {
63            continue;
64        }
65
66        let module_yaml = path.join("module.yaml");
67        if !module_yaml.exists() {
68            continue;
69        }
70
71        let name = path
72            .file_name()
73            .and_then(|n| n.to_str())
74            .ok_or_else(|| ConfigError::Invalid {
75                message: format!("invalid module directory name: {}", path.posix()),
76            })?
77            .to_string();
78
79        let contents = read_module_yaml_capped(&module_yaml)?;
80
81        let doc = parse_module(&contents)?;
82
83        if doc.metadata.name != name {
84            return Err(ModuleError::InvalidSpec {
85                name: name.clone(),
86                message: format!(
87                    "module directory '{}' does not match metadata.name '{}'",
88                    name, doc.metadata.name
89                ),
90            }
91            .into());
92        }
93
94        modules.insert(
95            name.clone(),
96            LoadedModule {
97                name,
98                spec: doc.spec,
99                dir: path,
100            },
101        );
102    }
103
104    Ok(modules)
105}
106
107/// Load a single module from a given directory.
108pub fn load_module(module_dir: &Path) -> Result<LoadedModule> {
109    let module_yaml = module_dir.join("module.yaml");
110    if !module_yaml.exists() {
111        let name = module_dir
112            .file_name()
113            .and_then(|n| n.to_str())
114            .ok_or_else(|| ModuleError::InvalidSpec {
115                name: module_dir.display_posix(),
116                message: "invalid module directory name".into(),
117            })?
118            .to_string();
119        return Err(ModuleError::NotFound { name }.into());
120    }
121
122    let contents = read_module_yaml_capped(&module_yaml)?;
123    let doc = parse_module(&contents)?;
124    let name = doc.metadata.name.clone();
125
126    Ok(LoadedModule {
127        name,
128        spec: doc.spec,
129        dir: module_dir.to_path_buf(),
130    })
131}
132
133// ---------------------------------------------------------------------------
134// Dependency resolution — topological sort with cycle detection
135// ---------------------------------------------------------------------------
136
137/// Resolve module dependencies using topological sort (Kahn's algorithm).
138/// Returns module names in dependency order (leaves first).
139pub fn resolve_dependency_order(
140    requested: &[String],
141    all_modules: &HashMap<String, LoadedModule>,
142) -> Result<Vec<String>> {
143    // Safety limits to prevent DoS from malicious module graphs
144    const MAX_MODULES: usize = 500;
145    const MAX_DEPENDENCY_DEPTH: usize = 50;
146
147    // Collect the full set of modules we need (requested + transitive deps)
148    let mut needed: HashSet<String> = HashSet::new();
149    let mut queue: VecDeque<(String, usize)> = requested.iter().map(|r| (r.clone(), 0)).collect();
150
151    while let Some((name, depth)) = queue.pop_front() {
152        if needed.contains(&name) {
153            continue;
154        }
155
156        if depth > MAX_DEPENDENCY_DEPTH {
157            return Err(ModuleError::DependencyCycle {
158                chain: vec![format!(
159                    "dependency depth exceeds {} (at '{}')",
160                    MAX_DEPENDENCY_DEPTH, name
161                )],
162            }
163            .into());
164        }
165
166        if needed.len() >= MAX_MODULES {
167            return Err(ModuleError::DependencyCycle {
168                chain: vec![format!("total module count exceeds {} limit", MAX_MODULES)],
169            }
170            .into());
171        }
172
173        let module = all_modules
174            .get(&name)
175            .ok_or_else(|| ModuleError::NotFound { name: name.clone() })?;
176
177        needed.insert(name.clone());
178
179        for dep in &module.spec.depends {
180            if !all_modules.contains_key(dep) {
181                return Err(ModuleError::MissingDependency {
182                    module: name.clone(),
183                    dependency: dep.clone(),
184                }
185                .into());
186            }
187            if !needed.contains(dep) {
188                queue.push_back((dep.clone(), depth + 1));
189            }
190        }
191    }
192
193    // Build adjacency and in-degree for the needed subset
194    let mut in_degree: HashMap<String, usize> = HashMap::new();
195    let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
196
197    for name in &needed {
198        in_degree.entry(name.clone()).or_insert(0);
199        let module = &all_modules[name];
200        for dep in &module.spec.depends {
201            if needed.contains(dep) {
202                *in_degree.entry(name.clone()).or_insert(0) += 1;
203                dependents
204                    .entry(dep.clone())
205                    .or_default()
206                    .push(name.clone());
207            }
208        }
209    }
210
211    // Kahn's algorithm
212    let mut queue: VecDeque<String> = in_degree
213        .iter()
214        .filter(|(_, deg)| **deg == 0)
215        .map(|(name, _)| name.clone())
216        .collect();
217
218    // Sort the initial queue for deterministic output
219    let mut sorted_initial: Vec<String> = queue.drain(..).collect();
220    sorted_initial.sort();
221    queue.extend(sorted_initial);
222
223    let mut order = Vec::new();
224
225    while let Some(name) = queue.pop_front() {
226        order.push(name.clone());
227
228        if let Some(deps) = dependents.get(&name) {
229            let mut next: Vec<String> = Vec::new();
230            for dep in deps {
231                if let Some(deg) = in_degree.get_mut(dep) {
232                    *deg -= 1;
233                    if *deg == 0 {
234                        next.push(dep.clone());
235                    }
236                }
237            }
238            // Sort for deterministic output
239            next.sort();
240            queue.extend(next);
241        }
242    }
243
244    if order.len() != needed.len() {
245        // Cycle detected — find the cycle members (use HashSet for O(1) lookup)
246        let ordered: HashSet<&str> = order.iter().map(|s| s.as_str()).collect();
247        let in_cycle: Vec<String> = needed
248            .into_iter()
249            .filter(|n| !ordered.contains(n.as_str()))
250            .collect();
251        return Err(ModuleError::DependencyCycle { chain: in_cycle }.into());
252    }
253
254    Ok(order)
255}