Skip to main content

dora_core/descriptor/
expand.rs

1use super::classify;
2use dora_message::{
3    config::{Input, InputMapping, LogSubscriptionFilter, UserInputMapping},
4    descriptor::{
5        DYNAMIC_SOURCE, Descriptor, EnvValue, Node, OperatorConfig, OperatorSource, SHELL_SOURCE,
6    },
7    id::{DataId, NodeId},
8};
9use eyre::{Context, bail};
10use serde::Deserialize;
11use std::{
12    collections::{BTreeMap, BTreeSet, HashSet},
13    path::{Path, PathBuf},
14};
15
16use super::normalize_path;
17
18/// Check if a path string is absolute on any platform.
19/// On Windows, `Path::is_absolute()` returns false for Unix-style `/foo` paths,
20/// so we also check for a leading `/` to catch cross-platform absolute paths.
21fn is_absolute_any_platform(path: &str) -> bool {
22    Path::new(path).is_absolute() || path.starts_with('/')
23}
24
25/// Maximum nesting depth for recursive module expansion. Prevents unbounded
26/// recursion from deeply nested or circular module graphs that evade the
27/// path-based cycle check. 8 levels covers realistic robotics pipelines
28/// while keeping memory usage bounded.
29const MAX_MODULE_DEPTH: u8 = 8;
30
31/// Maximum module file size (1 MB). Prevents DoS from huge or infinite files.
32const MAX_MODULE_FILE_SIZE: u64 = 1_048_576;
33
34/// Reserved node-ID prefix used inside module files to reference module inputs.
35/// Usage in module YAML: `_mod/port_name`
36const MODULE_INPUT_SOURCE: &str = "_mod";
37
38/// Where the outputs a module declares end up after expansion: maps each
39/// declared output name to the mapping a consumer must use to read it — the
40/// prefixed inner node ID plus the output name as that node addresses it
41/// (see [`node_output_refs`]).
42type ModuleOutputMap = BTreeMap<String, UserInputMapping>;
43
44/// Header section of a module definition file.
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47struct ModuleHeader {
48    name: String,
49    #[serde(default)]
50    inputs: Vec<DataId>,
51    #[serde(default)]
52    inputs_optional: Vec<DataId>,
53    #[serde(default)]
54    outputs: Vec<DataId>,
55}
56
57/// A module definition file (`*_module.yml`).
58#[derive(Debug, Clone, Deserialize)]
59#[serde(deny_unknown_fields)]
60struct ModuleFile {
61    module: ModuleHeader,
62    nodes: Vec<Node>,
63    /// Module-level build command, runs before inner node builds.
64    #[serde(default)]
65    build: Option<String>,
66}
67
68/// Metadata about which nodes came from which module, used for graph
69/// visualization with subgraph boundaries.
70#[derive(Debug, Clone, Default)]
71pub struct ModuleBoundaries {
72    /// Maps module_id -> list of expanded node IDs that belong to it.
73    pub modules: BTreeMap<String, Vec<String>>,
74}
75
76/// Result of expanding modules, including boundary metadata for visualization.
77#[derive(Debug, Clone)]
78pub struct ExpandedDescriptor {
79    pub descriptor: Descriptor,
80    pub boundaries: ModuleBoundaries,
81}
82
83/// Expand all module references in `descriptor` into flat nodes.
84///
85/// Module nodes are replaced by the inner nodes from the referenced module
86/// file. Internal IDs are prefixed with `{module_id}.` and all input/output
87/// wiring is rewritten so the result is a plain flat descriptor.
88pub fn expand_modules(descriptor: &Descriptor, base_dir: &Path) -> eyre::Result<Descriptor> {
89    Ok(expand_modules_with_boundaries(descriptor, base_dir)?.descriptor)
90}
91
92/// Like [`expand_modules`] but also returns module boundary metadata for
93/// visualization.
94pub fn expand_modules_with_boundaries(
95    descriptor: &Descriptor,
96    base_dir: &Path,
97) -> eyre::Result<ExpandedDescriptor> {
98    let has_modules = descriptor.nodes.iter().any(|n| n.module.is_some());
99    if !has_modules {
100        return Ok(ExpandedDescriptor {
101            descriptor: descriptor.clone(),
102            boundaries: ModuleBoundaries::default(),
103        });
104    }
105
106    let project_root = normalize_path(dunce::simplified(
107        &std::path::absolute(base_dir)
108            .with_context(|| format!("failed to make base_dir absolute: {}", base_dir.display()))?,
109    ));
110    let canonical_project_root = dunce::canonicalize(&project_root)
111        .with_context(|| format!("failed to resolve project root: {}", project_root.display()))?;
112    let mut seen = HashSet::new();
113    let mut flat_nodes = Vec::new();
114    let mut output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
115    let mut boundaries = ModuleBoundaries::default();
116
117    for node in &descriptor.nodes {
118        if node.module.is_some() {
119            // Field validation happens inside `expand_module_node`, so top-level
120            // and nested module nodes go through the same whitelist.
121            let (mut expanded, omap) = expand_module_node(
122                node,
123                base_dir,
124                &project_root,
125                &canonical_project_root,
126                0,
127                &mut seen,
128            )?;
129            // Propagate the module node's own `build` to each expanded leaf node,
130            // mirroring how a nested module node's build is propagated in Phase 2
131            // of `expand_module_node`. `check_module` accepts `build` for exactly
132            // this reason.
133            if let Some(ref outer_build) = node.build {
134                for expanded_node in &mut expanded {
135                    prepend_module_build_to_node(expanded_node, outer_build);
136                }
137            }
138            let module_id = node.id.to_string();
139            output_maps.insert(module_id.clone(), omap);
140            let node_ids: Vec<String> = expanded.iter().map(|n| n.id.to_string()).collect();
141            boundaries.modules.insert(module_id, node_ids);
142            flat_nodes.extend(expanded);
143        } else {
144            flat_nodes.push(node.clone());
145        }
146    }
147
148    rewrite_external_refs(&mut flat_nodes, &output_maps)?;
149
150    // Verify expanded node IDs are unique
151    let mut id_set = HashSet::new();
152    for node in &flat_nodes {
153        if !id_set.insert(node.id.to_string()) {
154            bail!(
155                "duplicate node ID `{}` after module expansion — check for \
156                 conflicting node names across modules and top-level nodes",
157                node.id
158            );
159        }
160    }
161
162    // Expansion rewrites `nodes` and nothing else, so clone the source
163    // descriptor and swap that one field. Listing the dataflow-level options
164    // individually would silently drop any option added later — `Descriptor` is
165    // `#[non_exhaustive]`, so a missing field is no longer a compile error. This
166    // also keeps the with-modules path structurally identical to the early
167    // return above, which already clones.
168    let mut expanded = descriptor.clone();
169    expanded.nodes = flat_nodes;
170
171    Ok(ExpandedDescriptor {
172        descriptor: expanded,
173        boundaries,
174    })
175}
176
177/// Validate a module file in isolation without expanding it into a dataflow.
178///
179/// Checks:
180/// - Module header is well-formed (required name, optional inputs/outputs)
181/// - All inner nodes are parseable
182/// - All `_mod/X` references point to declared inputs or optional inputs
183/// - All declared outputs are produced by some inner node (or nested module)
184/// - No circular references within the module
185pub fn check_module_file(module_path: &Path) -> eyre::Result<()> {
186    let canonical = module_path
187        .canonicalize()
188        .with_context(|| format!("module file not found: {}", module_path.display()))?;
189    let mut seen = HashSet::new();
190    check_module_file_inner(&canonical, 0, &mut seen)
191}
192
193fn check_module_file_inner(
194    canonical: &Path,
195    depth: u8,
196    seen: &mut HashSet<PathBuf>,
197) -> eyre::Result<()> {
198    if depth >= MAX_MODULE_DEPTH {
199        bail!(
200            "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} while checking module file: {}",
201            canonical.display()
202        );
203    }
204
205    if !seen.insert(canonical.to_path_buf()) {
206        bail!(
207            "circular module reference detected while checking module file: {}\n\
208             hint: check that module files do not reference each other in a cycle",
209            canonical.display()
210        );
211    }
212
213    let module_file = load_module_file(canonical)?;
214    validate_module_header(&module_file.module)?;
215    let module_dir = canonical
216        .parent()
217        .expect("module file must have a parent directory");
218
219    // Collect all declared + optional input names
220    let all_input_names: BTreeSet<String> = module_file
221        .module
222        .inputs
223        .iter()
224        .chain(module_file.module.inputs_optional.iter())
225        .map(|d| d.to_string())
226        .collect();
227
228    // Check _mod/ references point to declared inputs. Runtime (operator)
229    // and legacy custom nodes wire their inputs through config.inputs /
230    // run_config.inputs rather than the node-level `inputs` map, so those
231    // need the same check (see #2441).
232    for node in &module_file.nodes {
233        for inputs in node_input_maps(node) {
234            check_mod_refs(&module_file.module.name, &node.id, inputs, &all_input_names)?;
235        }
236    }
237    let module_outputs = collect_module_source_outputs(&module_file, module_dir)?;
238    check_internal_wiring(
239        &module_file.module.name,
240        &module_file.nodes,
241        &module_outputs,
242    )?;
243
244    // Check outputs: each declared output should be produced by some inner node.
245    // For nested module children, recursively load their declared outputs.
246    // Runtime (operator) and legacy custom nodes declare their outputs in
247    // config.outputs / run_config.outputs rather than the node-level `outputs`
248    // set, so `node_output_refs` collects those too (see #2817).
249    let mut inner_outputs: BTreeMap<String, Vec<String>> = BTreeMap::new();
250    for node in module_file.nodes.iter().filter(|n| n.module.is_none()) {
251        for (name, output_ref) in node_output_refs(node) {
252            inner_outputs
253                .entry(name)
254                .or_default()
255                .push(format!("{}/{}", node.id, output_ref));
256        }
257    }
258
259    // Check nested module files exist and collect their declared outputs
260    for node in &module_file.nodes {
261        if let Some(ref mod_path) = node.module {
262            if is_absolute_any_platform(mod_path) {
263                bail!(
264                    "module `{}`: nested module path `{}` must be relative (node `{}`)",
265                    module_file.module.name,
266                    mod_path,
267                    node.id,
268                );
269            }
270            // The same field whitelist applies at every nesting level.
271            // Without this, `dora expand --module m.yml` reports a file as
272            // valid while `dora run` on a dataflow using it hard-fails.
273            classify::check_module(node)
274                .with_context(|| format!("invalid module node `{}`", node.id))?;
275            let nested = module_dir.join(mod_path);
276            let nested_canonical = nested.canonicalize().with_context(|| {
277                format!(
278                    "module `{}`: nested module `{}` referenced by node `{}` not found",
279                    module_file.module.name, mod_path, node.id,
280                )
281            })?;
282            // Note: unlike `expand_module_node`, we intentionally do NOT reject
283            // a nested reference that leaves `module_dir`. The real expansion
284            // path confines nested modules to the *project root*
285            // (`project_root`, threaded through recursion), which routinely
286            // sits above an individual module's directory -- a module in
287            // `modules/a/` may reference a sibling module in `modules/shared/`
288            // via `../shared/base.yml`. This linter runs on a module file in
289            // isolation, with no project root to bound against, so a
290            // `module_dir` containment check would spuriously reject
291            // cross-directory references that `dora run` / `dora build` accept
292            // and run fine (see #2851). We keep the absolute-path rejection and
293            // the `canonicalize()` existence check above; the containment
294            // boundary is enforced by the real expansion path, not this lint.
295            //
296            // Load nested module to collect its declared outputs.
297            let nested_module = load_module_file(&nested_canonical)?;
298            check_nested_module_required_inputs(
299                &module_file.module.name,
300                &node.id,
301                &nested_module.module,
302                &node.inputs,
303            )?;
304            check_module_file_inner(&nested_canonical, depth + 1, seen).with_context(|| {
305                format!(
306                    "module `{}`: while checking nested module `{}` referenced by node `{}`",
307                    module_file.module.name, nested_module.module.name, node.id,
308                )
309            })?;
310            for output in &nested_module.module.outputs {
311                inner_outputs
312                    .entry(output.to_string())
313                    .or_default()
314                    .push(format!("{}/{}", node.id, output));
315            }
316        }
317    }
318
319    for declared_output in &module_file.module.outputs {
320        let output_str = declared_output.to_string();
321        match inner_outputs.get(&output_str) {
322            None => {
323                bail!(
324                    "module `{}` declares output `{}` but no inner node produces it",
325                    module_file.module.name,
326                    declared_output,
327                );
328            }
329            Some(producers) if producers.len() > 1 => {
330                bail!(
331                    "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
332                    module_file.module.name,
333                    declared_output,
334                    producers.join(", "),
335                );
336            }
337            Some(_) => {}
338        }
339    }
340
341    seen.remove(canonical);
342    Ok(())
343}
344
345fn collect_module_source_outputs(
346    module_file: &ModuleFile,
347    module_dir: &Path,
348) -> eyre::Result<BTreeMap<String, BTreeSet<String>>> {
349    let mut outputs = BTreeMap::new();
350    for node in &module_file.nodes {
351        let node_id = node.id.to_string();
352        if outputs.contains_key(&node_id) {
353            bail!(
354                "module `{}` has duplicate node ID `{}`",
355                module_file.module.name,
356                node.id,
357            );
358        }
359
360        let node_outputs = if let Some(ref mod_path) = node.module {
361            if is_absolute_any_platform(mod_path) {
362                bail!(
363                    "module `{}`: nested module path `{}` must be relative (node `{}`)",
364                    module_file.module.name,
365                    mod_path,
366                    node.id,
367                );
368            }
369            let nested = module_dir.join(mod_path);
370            let nested_canonical = nested.canonicalize().with_context(|| {
371                format!(
372                    "module `{}`: nested module `{}` referenced by node `{}` not found",
373                    module_file.module.name, mod_path, node.id,
374                )
375            })?;
376            let nested_module = load_module_file(&nested_canonical)?;
377            nested_module
378                .module
379                .outputs
380                .iter()
381                .map(|output| output.to_string())
382                .collect()
383        } else {
384            node_output_refs(node)
385                .into_iter()
386                .map(|(_, output_ref)| output_ref)
387                .collect()
388        };
389
390        outputs.insert(node_id, node_outputs);
391    }
392    Ok(outputs)
393}
394
395fn check_internal_wiring(
396    module_name: &str,
397    nodes: &[Node],
398    module_outputs: &BTreeMap<String, BTreeSet<String>>,
399) -> eyre::Result<()> {
400    for node in nodes {
401        for inputs in node_input_maps(node) {
402            for (input_id, input) in inputs {
403                if let InputMapping::User(mapping) = &input.mapping {
404                    let source = mapping.source.to_string();
405                    if source == MODULE_INPUT_SOURCE {
406                        continue;
407                    }
408                    if let Some(outputs) = module_outputs.get(&source) {
409                        let output = mapping.output.to_string();
410                        if !outputs.contains(&output) {
411                            bail!(
412                                "module `{}`: node `{}` input `{}` references \
413                                 `{}/{}` but that output is not produced",
414                                module_name,
415                                node.id,
416                                input_id,
417                                source,
418                                output,
419                            );
420                        }
421                    }
422                }
423            }
424        }
425    }
426    Ok(())
427}
428
429fn validate_module_header(module: &ModuleHeader) -> eyre::Result<()> {
430    reject_duplicate_ports(&module.name, "inputs", &module.inputs)?;
431    reject_duplicate_ports(&module.name, "inputs_optional", &module.inputs_optional)?;
432    reject_duplicate_ports(&module.name, "outputs", &module.outputs)?;
433
434    let required: BTreeSet<_> = module.inputs.iter().collect();
435    if let Some(overlap) = module
436        .inputs_optional
437        .iter()
438        .find(|input| required.contains(input))
439    {
440        bail!(
441            "module `{}` input `{}` is declared as both required and optional",
442            module.name,
443            overlap
444        );
445    }
446    Ok(())
447}
448
449fn reject_duplicate_ports(module_name: &str, field: &str, ports: &[DataId]) -> eyre::Result<()> {
450    let mut seen = BTreeSet::new();
451    for port in ports {
452        if !seen.insert(port) {
453            bail!("module `{module_name}` has duplicate `{field}` entry `{port}`");
454        }
455    }
456    Ok(())
457}
458
459fn check_nested_module_required_inputs(
460    module_name: &str,
461    node_id: &NodeId,
462    nested_module: &ModuleHeader,
463    node_inputs: &BTreeMap<DataId, Input>,
464) -> eyre::Result<()> {
465    for declared_input in &nested_module.inputs {
466        if !node_inputs.contains_key(declared_input) {
467            bail!(
468                "module `{}`: nested module `{}` declares required input `{}` \
469                 but node `{}` does not provide it",
470                module_name,
471                nested_module.name,
472                declared_input,
473                node_id,
474            );
475        }
476    }
477    Ok(())
478}
479
480/// Check that every `_mod/X` reference in `inputs` points to a declared
481/// module input (or optional input). Shared by the node-level `inputs`
482/// check and the operator/custom `config.inputs` / `run_config.inputs`
483/// checks in [`check_module_file`].
484fn check_mod_refs(
485    module_name: &str,
486    node_id: &NodeId,
487    inputs: &BTreeMap<DataId, Input>,
488    all_input_names: &BTreeSet<String>,
489) -> eyre::Result<()> {
490    for (input_id, input) in inputs {
491        if let InputMapping::User(m) = &input.mapping
492            && m.source.to_string() == MODULE_INPUT_SOURCE
493        {
494            let port = m.output.to_string();
495            if !all_input_names.contains(&port) {
496                bail!(
497                    "module `{}`: node `{}` input `{}` references \
498                         `_mod/{}` but `{}` is not declared in module \
499                         inputs or inputs_optional",
500                    module_name,
501                    node_id,
502                    input_id,
503                    port,
504                    port,
505                );
506            }
507        }
508    }
509    Ok(())
510}
511
512/// All the places a node's inputs can live: the node-level `inputs` map,
513/// plus the operator/custom `config.inputs` / `run_config.inputs` maps for
514/// runtime (operator) and legacy custom inner nodes. Centralizes the
515/// node-kind enumeration so `_mod/`-reference validation and rewriting stay
516/// in sync as node kinds are added or change (see #2441).
517fn node_input_maps(node: &Node) -> Vec<&BTreeMap<DataId, Input>> {
518    let mut maps = vec![&node.inputs];
519    if let Some(ref operators) = node.operators {
520        maps.extend(operators.operators.iter().map(|op| &op.config.inputs));
521    }
522    if let Some(ref operator) = node.operator {
523        maps.push(&operator.config.inputs);
524    }
525    maps
526}
527
528/// Mutable counterpart of [`node_input_maps`], used by the Phase 1 rewrite
529/// pass in [`expand_module_node`].
530fn node_input_maps_mut(node: &mut Node) -> Vec<&mut BTreeMap<DataId, Input>> {
531    let mut maps = vec![&mut node.inputs];
532    if let Some(ref mut operators) = node.operators {
533        maps.extend(
534            operators
535                .operators
536                .iter_mut()
537                .map(|op| &mut op.config.inputs),
538        );
539    }
540    if let Some(ref mut operator) = node.operator {
541        maps.push(&mut operator.config.inputs);
542    }
543    maps
544}
545
546/// Every output an inner node produces, as `(output_name, output_ref)` pairs
547/// where `output_ref` is how a consumer addresses that output on this node.
548///
549/// The two differ only for multi-operator runtime nodes: their outputs live in
550/// `operators[].config.outputs` and must be referenced as
551/// `<operator_id>/<output>`. Node-level `outputs` and single `operator:`
552/// outputs are both referenced by
553/// their bare name — for `operator:` the `op/` prefix is injected later by
554/// `resolve_aliases_and_set_defaults`, so adding it here would double it up.
555///
556/// Output-side counterpart of [`node_input_maps`]: centralizes the node-kind
557/// enumeration so module output resolution stays in sync as node kinds are
558/// added or change (see #2817).
559fn node_output_refs(node: &Node) -> Vec<(String, String)> {
560    fn bare(outputs: &BTreeSet<DataId>) -> impl Iterator<Item = (String, String)> {
561        outputs.iter().map(|o| (o.to_string(), o.to_string()))
562    }
563
564    let mut refs: Vec<(String, String)> = bare(&node.outputs).collect();
565    if let Some(ref operators) = node.operators {
566        for op in &operators.operators {
567            refs.extend(
568                op.config
569                    .outputs
570                    .iter()
571                    .map(|o| (o.to_string(), format!("{}/{o}", op.id))),
572            );
573        }
574    }
575    if let Some(ref operator) = node.operator {
576        refs.extend(bare(&operator.config.outputs));
577    }
578    refs
579}
580
581/// Expand a single module node into its constituent flat nodes.
582///
583/// Returns `(expanded_nodes, output_map)`; see [`ModuleOutputMap`].
584fn expand_module_node(
585    node: &Node,
586    base_dir: &Path,
587    project_root: &Path,
588    canonical_project_root: &Path,
589    depth: u8,
590    seen: &mut HashSet<PathBuf>,
591) -> eyre::Result<(Vec<Node>, ModuleOutputMap)> {
592    if depth >= MAX_MODULE_DEPTH {
593        bail!(
594            "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} \
595             (node `{}`)",
596            node.id
597        );
598    }
599
600    // Validate the module node's fields against the module whitelist. This is
601    // the single validation site for both top-level and nested module nodes, so
602    // a field that has no meaning on a module node (e.g. `outputs`,
603    // `cpu_affinity`) is rejected here rather than silently dropped during
604    // expansion, at every nesting level.
605    classify::check_module(node).with_context(|| format!("invalid module node `{}`", node.id))?;
606
607    let module_path_str = node
608        .module
609        .as_ref()
610        .expect("expand_module_node called on non-module node");
611
612    // Security: reject absolute paths and path traversal
613    if is_absolute_any_platform(module_path_str) {
614        bail!(
615            "module path `{}` must be relative (node `{}`)",
616            module_path_str,
617            node.id
618        );
619    }
620
621    // Find the canonical target of the first symlink component, if any
622    let mut current = base_dir.to_path_buf();
623    let mut symlink_target_root: Option<PathBuf> = None;
624    for component in Path::new(module_path_str).components() {
625        match component {
626            std::path::Component::Normal(c) => {
627                current.push(c);
628                if let Ok(meta) = std::fs::symlink_metadata(&current)
629                    && meta.is_symlink()
630                    && symlink_target_root.is_none()
631                {
632                    symlink_target_root = dunce::canonicalize(&current).ok();
633                }
634            }
635            std::path::Component::ParentDir => {
636                if let Ok(meta) = std::fs::symlink_metadata(&current)
637                    && meta.is_symlink()
638                {
639                    let target = dunce::canonicalize(&current).ok();
640                    let is_in_tree = target
641                        .as_ref()
642                        .is_some_and(|t| t.starts_with(canonical_project_root));
643                    if !is_in_tree {
644                        bail!(
645                            "module path `{}` escapes the project directory (node `{}`)",
646                            module_path_str,
647                            node.id
648                        );
649                    }
650                }
651                current.pop();
652            }
653            _ => {}
654        }
655    }
656
657    let module_path = base_dir.join(module_path_str);
658    let canonical = dunce::canonicalize(&module_path)
659        .with_context(|| format!("module file not found: {}", module_path.display()))?;
660
661    let absolute_module = normalize_path(dunce::simplified(
662        &std::path::absolute(&module_path).with_context(|| {
663            format!(
664                "failed to make module path absolute: {}",
665                module_path.display()
666            )
667        })?,
668    ));
669    if !absolute_module.starts_with(project_root) {
670        bail!(
671            "module path `{}` escapes the project directory (node `{}`)",
672            module_path_str,
673            node.id
674        );
675    }
676
677    // Lexical and physical resolutions must name the same file. This prevents
678    // `..` traversal over symlinks from escaping, while leaving in-tree symlinks working.
679    if dunce::canonicalize(&absolute_module).ok().as_ref() != Some(&canonical) {
680        bail!(
681            "module path `{}` escapes the project directory (node `{}`)",
682            module_path_str,
683            node.id
684        );
685    }
686
687    // Confinement: the loaded physical file must either reside within the
688    // canonical project root, or within the canonical target of a symlink
689    // located inside the project directory.
690    let in_project = canonical.starts_with(canonical_project_root);
691    let in_symlink = symlink_target_root
692        .as_ref()
693        .is_some_and(|target| canonical.starts_with(target));
694
695    if !in_project && !in_symlink {
696        bail!(
697            "module path `{}` physically escapes the project directory (node `{}`)",
698            module_path_str,
699            node.id
700        );
701    }
702
703    if !seen.insert(canonical.clone()) {
704        bail!(
705            "circular module reference detected: {} (node `{}`)\n\
706             hint: check that module files do not reference each other \
707             in a cycle",
708            module_path.display(),
709            node.id
710        );
711    }
712
713    let module_file = load_module_file(&canonical)?;
714    validate_module_header(&module_file.module)?;
715    let module_id = node.id.to_string();
716    let module_dir = absolute_module
717        .parent()
718        .expect("module file must have a parent directory");
719
720    // Validate: all required module inputs are provided by the node's inputs
721    for declared_input in &module_file.module.inputs {
722        if !node.inputs.contains_key(declared_input) {
723            bail!(
724                "module `{}` declares required input `{}` but node `{}` \
725                 does not provide it\n\
726                 hint: add `{}: <source_node>/<output>` to the node's inputs",
727                module_file.module.name,
728                declared_input,
729                node.id,
730                declared_input,
731            );
732        }
733    }
734    // Optional inputs: no error if missing — inner nodes referencing them
735    // will simply have their input removed during rewrite.
736
737    // Build optional input set once for fast lookup
738    let optional_inputs: BTreeSet<String> = module_file
739        .module
740        .inputs_optional
741        .iter()
742        .map(|d| d.to_string())
743        .collect();
744    let declared_inputs: BTreeSet<String> = module_file
745        .module
746        .inputs
747        .iter()
748        .chain(module_file.module.inputs_optional.iter())
749        .map(|d| d.to_string())
750        .collect();
751    for provided_input in node.inputs.keys() {
752        let provided = provided_input.to_string();
753        if !declared_inputs.contains(&provided) {
754            bail!(
755                "module `{}` does not declare input `{}` provided by node `{}`\n\
756                 hint: declared inputs are: {}",
757                module_file.module.name,
758                provided_input,
759                node.id,
760                declared_inputs
761                    .iter()
762                    .cloned()
763                    .collect::<Vec<_>>()
764                    .join(", "),
765            );
766        }
767    }
768
769    // Validate and collect params
770    let mut seen_upper: BTreeMap<String, &String> = BTreeMap::new();
771    for key in node.params.keys() {
772        if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || key.is_empty() {
773            bail!(
774                "invalid param key `{}` in node `{}`: must be non-empty and \
775                 contain only [A-Za-z0-9_]",
776                key,
777                node.id
778            );
779        }
780        // Params are injected into the node env as `PARAM_<KEY>` with the key
781        // upper-cased (see `substitute_params_in_node`). Two keys that differ
782        // only in case (e.g. `mode` and `Mode`) would both map to `PARAM_MODE`,
783        // so one would silently overwrite the other. Reject the collision
784        // instead of dropping a param.
785        let upper = key.to_uppercase();
786        if let Some(existing) = seen_upper.insert(upper.clone(), key) {
787            bail!(
788                "param keys `{}` and `{}` in node `{}` collide: both map to the \
789                 env var `PARAM_{}`. Param keys must be unique case-insensitively.",
790                existing,
791                key,
792                node.id,
793                upper
794            );
795        }
796    }
797    let params = &node.params;
798
799    // Build set of inner node IDs for cross-reference rewriting
800    let inner_node_ids: BTreeSet<String> =
801        module_file.nodes.iter().map(|n| n.id.to_string()).collect();
802
803    // Phase 1: prefix IDs, rewrite inputs, resolve paths, substitute params
804    let mut prefixed_nodes = Vec::new();
805    for mut inner_node in module_file.nodes {
806        let prefixed_id: NodeId = format!("{module_id}.{}", inner_node.id).into();
807        inner_node.id = prefixed_id;
808
809        // Rewrite inputs (None = optional input not provided, skip it).
810        // Runtime (operator) nodes and legacy custom nodes wire their inputs
811        // through config.inputs / run_config.inputs rather than the
812        // node-level `inputs` map, so `node_input_maps_mut` rewrites all of
813        // them the same way — otherwise `_mod/` references and sibling
814        // cross-references wouldn't resolve for operator/custom inner nodes
815        // (see #2441).
816        for inputs in node_input_maps_mut(&mut inner_node) {
817            *inputs = rewrite_module_inputs_map(
818                inputs,
819                &module_id,
820                &node.inputs,
821                &inner_node_ids,
822                &optional_inputs,
823            )?;
824        }
825
826        resolve_inner_node_paths(&mut inner_node, module_dir, project_root)?;
827
828        // Propagate deploy from module node to inner nodes
829        if inner_node.deploy.is_none() {
830            inner_node.deploy = node.deploy.clone();
831        }
832
833        propagate_module_node_env(&mut inner_node, node.env.as_ref());
834
835        // Substitute params in env values
836        if !params.is_empty() {
837            substitute_params_in_node(&mut inner_node, params);
838        }
839
840        // Prepend module-level build command to inner node builds
841        if let Some(ref module_build) = module_file.build {
842            prepend_module_build_to_node(&mut inner_node, module_build);
843        }
844
845        prefixed_nodes.push(inner_node);
846    }
847
848    // Phase 2: recursively expand nested modules (before building output map)
849    // Collect nested output maps so sibling nodes can reference nested module
850    // outputs correctly via rewrite_external_refs.
851    let mut nested_output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
852    let mut direct_output_targets: BTreeMap<String, Vec<(String, UserInputMapping)>> =
853        BTreeMap::new();
854    let mut final_nodes = Vec::new();
855    for inner_node in prefixed_nodes {
856        if inner_node.module.is_some() {
857            let nested_id = inner_node.id.to_string();
858            let accumulated_build = inner_node.build.clone();
859            let (mut nested, nested_omap) = expand_module_node(
860                &inner_node,
861                module_dir,
862                project_root,
863                canonical_project_root,
864                depth + 1,
865                seen,
866            )?;
867            // Propagate the outer module's accumulated build to each nested leaf node,
868            // mirroring how `deploy` is propagated through recursion.
869            if let Some(ref outer_build) = accumulated_build {
870                for nested_node in &mut nested {
871                    prepend_module_build_to_node(nested_node, outer_build);
872                }
873            }
874            for (output, target) in &nested_omap {
875                direct_output_targets
876                    .entry(output.clone())
877                    .or_default()
878                    .push((format!("{nested_id}/{output}"), target.clone()));
879            }
880            nested_output_maps.insert(nested_id, nested_omap);
881            final_nodes.extend(nested);
882        } else {
883            for (name, output_ref) in node_output_refs(&inner_node) {
884                // `output_ref` may be an operator-qualified `<op_id>/<output>`
885                // form, and `OperatorId` is unvalidated, so parse fallibly
886                // instead of `output_ref.into()` — `DataId::from` panics on
887                // characters outside `[a-zA-Z0-9_./-]`, which would abort
888                // expansion on an otherwise-parseable descriptor. Mirrors
889                // `prefix_output_with_operator_id` in `descriptor/mod.rs`.
890                let output: DataId = output_ref.parse().map_err(|e| {
891                    eyre::eyre!(
892                        "node `{}` produces an invalid output id `{output_ref}`: {e}",
893                        inner_node.id
894                    )
895                })?;
896                direct_output_targets.entry(name).or_default().push((
897                    format!("{}/{output}", inner_node.id),
898                    UserInputMapping {
899                        source: inner_node.id.clone(),
900                        output,
901                    },
902                ));
903            }
904            final_nodes.push(inner_node);
905        }
906    }
907
908    // Rewrite sibling references that point to nested module outputs
909    if !nested_output_maps.is_empty() {
910        rewrite_external_refs(&mut final_nodes, &nested_output_maps)?;
911    }
912
913    // Phase 3: build output map from direct children only. A producer may
914    // be a plain node, a runtime node's operator, or a legacy custom node, so
915    // the lookup goes through `node_output_refs` — which also yields the form
916    // consumers must use to address the output (see #2817).
917    let mut output_map = ModuleOutputMap::new();
918    for declared_output in &module_file.module.outputs {
919        let declared = declared_output.to_string();
920        let target = match direct_output_targets.get(&declared).map(Vec::as_slice) {
921            None | Some([]) => {
922                bail!(
923                    "module `{}` declares output `{}` but no inner node produces it",
924                    module_file.module.name,
925                    declared_output,
926                );
927            }
928            Some([(_, target)]) => target.clone(),
929            Some(targets) => {
930                let producers = targets
931                    .iter()
932                    .map(|(producer, _)| producer.as_str())
933                    .collect::<Vec<_>>()
934                    .join(", ");
935                bail!(
936                    "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
937                    module_file.module.name,
938                    declared_output,
939                    producers,
940                );
941            }
942        };
943        output_map.insert(declared, target);
944    }
945
946    // Remove from seen so the same module file can be used in different
947    // branches (multiple instances)
948    seen.remove(&canonical);
949
950    Ok((final_nodes, output_map))
951}
952
953fn resolve_inner_node_paths(
954    node: &mut Node,
955    module_dir: &Path,
956    project_root: &Path,
957) -> eyre::Result<()> {
958    let owner = node.id.to_string();
959    // A `git:` node's `path` is relative to its clone (the node's working dir
960    // at build and spawn), not to the module file, so leave it untouched.
961    if node.git.is_none()
962        && let Some(ref mut path) = node.path
963    {
964        resolve_module_relative_path(path, module_dir, project_root, &owner)?;
965    }
966    if let Some(ref mut operators) = node.operators {
967        for op in &mut operators.operators {
968            resolve_operator_source_paths(&mut op.config, module_dir, project_root, &owner)?;
969        }
970    }
971    if let Some(ref mut operator) = node.operator {
972        resolve_operator_source_paths(&mut operator.config, module_dir, project_root, &owner)?;
973    }
974    Ok(())
975}
976
977fn resolve_operator_source_paths(
978    config: &mut OperatorConfig,
979    module_dir: &Path,
980    project_root: &Path,
981    owner: &str,
982) -> eyre::Result<()> {
983    match &mut config.source {
984        OperatorSource::SharedLibrary(path) | OperatorSource::Wasm(path) => {
985            resolve_module_relative_path(path, module_dir, project_root, owner)
986        }
987        OperatorSource::Python(source) => {
988            resolve_module_relative_path(&mut source.source, module_dir, project_root, owner)
989        }
990    }
991}
992
993fn resolve_module_relative_path(
994    path: &mut String,
995    module_dir: &Path,
996    project_root: &Path,
997    owner: &str,
998) -> eyre::Result<()> {
999    // Resolve relative paths: make inner node/operator sources relative to
1000    // base_dir. Normalize lexically (collapse `..`) before the containment
1001    // check so paths like `../../evil` are detected even if the binary does
1002    // not exist yet (ruling out filesystem canonicalize).
1003    if path == DYNAMIC_SOURCE
1004        || path == SHELL_SOURCE
1005        || super::source_is_url(path)
1006        // `Path::is_absolute()` is false for a Unix-style `/foo` on Windows,
1007        // where `module_dir.join("/foo")` then rewrites it into the module
1008        // directory and the confinement check below rejects it with a
1009        // misleading "resolves outside the project directory".
1010        || is_absolute_any_platform(path)
1011    {
1012        return Ok(());
1013    }
1014
1015    let resolved = normalize_path(&module_dir.join(path.as_str()));
1016    let relative = resolved.strip_prefix(project_root).map_err(|_| {
1017        eyre::eyre!(
1018            "module node `{}` path `{}` resolves outside the project \
1019                 directory (resolved to `{}`)",
1020            owner,
1021            path,
1022            resolved.display()
1023        )
1024    })?;
1025    *path = relative.to_string_lossy().into_owned();
1026    Ok(())
1027}
1028
1029fn prepend_module_build_to_node(node: &mut Node, module_build: &str) {
1030    // The node-level `build` is consumed by every standard node -- `path:`,
1031    // `git:`, and `hub:` sourced -- (`build/mod.rs` reads `n.build` for all
1032    // `CoreNodeKind::Custom` nodes) and by nested `module:` nodes (which
1033    // forward it to their own leaves in Phase 2). It is *not* valid on
1034    // runtime/operator/ROS2 nodes, where the field classifier rejects it, so
1035    // for those the module build lands in the operator configs only.
1036    //
1037    // Do NOT key this off `node.path`: module expansion runs *before* git/hub
1038    // source resolution, so a `git:`/`hub:`-sourced inner node still has
1039    // `path == None` here. Keying off `path` silently dropped the module build
1040    // for those nodes (#3296). Gate on "not a runtime/operator/ROS2 node"
1041    // instead, which correctly includes git/hub standard leaves.
1042    let is_standard_or_module = node.module.is_some()
1043        || (node.operators.is_none() && node.operator.is_none() && node.ros2.is_none());
1044    if is_standard_or_module {
1045        prepend_build(&mut node.build, module_build);
1046    }
1047    if let Some(ref mut operators) = node.operators {
1048        for op in &mut operators.operators {
1049            prepend_build(&mut op.config.build, module_build);
1050        }
1051    }
1052    if let Some(ref mut operator) = node.operator {
1053        prepend_build(&mut operator.config.build, module_build);
1054    }
1055}
1056
1057fn prepend_build(build: &mut Option<String>, module_build: &str) {
1058    let existing = build.take();
1059    *build = Some(match existing {
1060        Some(existing) => format!("{module_build}\n{existing}"),
1061        None => module_build.to_string(),
1062    });
1063}
1064
1065/// Fill an inner node's `env` with entries from the module node that references
1066/// it.
1067///
1068/// The inner node's own `env` wins on conflict: the module node's entries only
1069/// fill keys the inner node did not set. This matches `merge_env`'s "per-node
1070/// entries override global ones" and the `deploy` propagation above, which also
1071/// only fills when the inner value is unset.
1072fn propagate_module_node_env(
1073    inner_node: &mut Node,
1074    module_env: Option<&BTreeMap<String, EnvValue>>,
1075) {
1076    let Some(module_env) = module_env.filter(|env| !env.is_empty()) else {
1077        return;
1078    };
1079    let env = inner_node.env.get_or_insert_with(BTreeMap::new);
1080    for (key, value) in module_env {
1081        env.entry(key.clone()).or_insert_with(|| value.clone());
1082    }
1083}
1084
1085/// Substitute `${_param.name}` and `$PARAM_<NAME>` references in a node's args
1086/// and inject params into the node's env map as `EnvValue::String` entries.
1087fn substitute_params_in_node(node: &mut Node, params: &BTreeMap<String, String>) {
1088    // Substitute in args
1089    if let Some(ref mut args) = node.args {
1090        *args = substitute_params_in_str(args, params);
1091    }
1092
1093    // Inject params into env map (caller params override inner defaults)
1094    let env = node.env.get_or_insert_with(BTreeMap::new);
1095    for (key, value) in params {
1096        env.insert(
1097            format!("PARAM_{}", key.to_uppercase()),
1098            EnvValue::String(value.clone()),
1099        );
1100    }
1101}
1102
1103/// Replace every parameter reference with its value in a single left-to-right
1104/// pass. Two forms are accepted:
1105///
1106/// - `${_param.<key>}` -- delimited; the key is matched case-sensitively and
1107///   the closing brace terminates it.
1108/// - `$PARAM_<KEY>` -- the documented shell-style form (see `docs/modules.md`),
1109///   matched against the upper-cased key. Having no delimiter it extends over
1110///   the maximal run of `[A-Za-z0-9_]`, so `$PARAM_SPEED_LIMIT` is a single
1111///   token and never partially matches a declared `speed` (#2901).
1112///
1113/// A previous implementation looped over the params calling `String::replace`
1114/// on the accumulating result, which had two problems: the outcome depended on
1115/// `BTreeMap` key ordering, and a parameter *value* that itself contained a
1116/// `${_param.…}` token (or a literal one a user wanted to keep) was expanded
1117/// transitively. Scanning once and never re-examining substituted text makes
1118/// substitution simultaneous and order-independent. Unknown keys are left
1119/// verbatim, matching the old behavior of only replacing keys present in
1120/// `params`.
1121fn substitute_params_in_str(s: &str, params: &BTreeMap<String, String>) -> String {
1122    const BRACED: &str = "${_param.";
1123    const ENV_STYLE: &str = "$PARAM_";
1124
1125    // `$PARAM_<KEY>` matches against the uppercased param name, the same
1126    // mapping `substitute_params_in_node` uses for the injected env vars.
1127    // Keys that differ only in case are rejected before we get here, so this
1128    // map cannot silently drop a param.
1129    let env_style: BTreeMap<String, &String> = params
1130        .iter()
1131        .map(|(key, value)| (key.to_uppercase(), value))
1132        .collect();
1133
1134    let mut result = String::with_capacity(s.len());
1135    let mut rest = s;
1136    while let Some(start) = rest.find('$') {
1137        result.push_str(&rest[..start]);
1138        let at_token = &rest[start..];
1139
1140        if let Some(after_prefix) = at_token.strip_prefix(BRACED) {
1141            match after_prefix.find('}') {
1142                Some(end) => {
1143                    let key = &after_prefix[..end];
1144                    match params.get(key) {
1145                        Some(value) => result.push_str(value),
1146                        // Unknown key: emit the token unchanged rather than dropping it.
1147                        None => {
1148                            result.push_str(BRACED);
1149                            result.push_str(key);
1150                            result.push('}');
1151                        }
1152                    }
1153                    // Continue *after* the closing brace so a substituted value is
1154                    // never re-scanned for further tokens.
1155                    rest = &after_prefix[end + 1..];
1156                }
1157                // No closing brace: emit the prefix literally and continue past it
1158                // (guarantees progress, so the loop always terminates).
1159                None => {
1160                    result.push_str(BRACED);
1161                    rest = after_prefix;
1162                }
1163            }
1164        } else if let Some(after_prefix) = at_token.strip_prefix(ENV_STYLE) {
1165            // The undelimited form ends at the first character that cannot be
1166            // part of a shell identifier, so `$PARAM_SPEED_LIMIT` is one token
1167            // and never matches a declared `speed` as a prefix (#2901).
1168            let end = after_prefix
1169                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1170                .unwrap_or(after_prefix.len());
1171            let key = &after_prefix[..end];
1172            match env_style.get(key) {
1173                Some(value) => result.push_str(value),
1174                // Unknown key: emit the token unchanged rather than dropping it.
1175                None => {
1176                    result.push_str(ENV_STYLE);
1177                    result.push_str(key);
1178                }
1179            }
1180            rest = &after_prefix[end..];
1181        } else {
1182            // A bare `$` that starts neither form: emit it and step past, so
1183            // the loop always makes progress.
1184            result.push('$');
1185            rest = &at_token[1..];
1186        }
1187    }
1188    result.push_str(rest);
1189    result
1190}
1191
1192/// Rewrite a single input mapping inside a module's inner node.
1193///
1194/// - `_mod/X` references are resolved to the actual source from the module
1195///   node's inputs map. Returns `None` if the port is optional and not provided.
1196/// - Internal cross-references (`sibling_node/output`) are prefixed with the
1197///   module ID.
1198/// - Timer inputs pass through unchanged.
1199fn rewrite_module_input(
1200    input: &Input,
1201    module_id: &str,
1202    module_inputs: &BTreeMap<DataId, Input>,
1203    inner_node_ids: &BTreeSet<String>,
1204    optional_inputs: &BTreeSet<String>,
1205) -> eyre::Result<Option<Input>> {
1206    match &input.mapping {
1207        // `dora/logs/<level>/<node>` naming a sibling: prefix the node filter
1208        // like a sibling data source, or it would match no node after
1209        // expansion and the sink would silently receive nothing.
1210        InputMapping::Logs(LogSubscriptionFilter {
1211            min_level,
1212            node_filter: Some(node),
1213        }) if inner_node_ids.contains(node.as_ref()) => Ok(Some(Input {
1214            mapping: InputMapping::Logs(LogSubscriptionFilter {
1215                min_level: min_level.clone(),
1216                node_filter: Some(format!("{module_id}.{node}").into()),
1217            }),
1218            ..input.clone()
1219        })),
1220        InputMapping::Timer { .. } | InputMapping::Logs(_) => Ok(Some(input.clone())),
1221        InputMapping::User(user_mapping) => {
1222            let source_str = user_mapping.source.to_string();
1223
1224            if source_str == MODULE_INPUT_SOURCE {
1225                // `_mod/port_name` -> resolve from module node's inputs
1226                let port_name = user_mapping.output.to_string();
1227                match module_inputs.get(&*port_name) {
1228                    Some(bound_input) => {
1229                        // Preserve queue_size/timeout from the inner node if set,
1230                        // otherwise fall back to the module node's binding
1231                        Ok(Some(Input {
1232                            mapping: bound_input.mapping.clone(),
1233                            queue_size: input.queue_size.or(bound_input.queue_size),
1234                            input_timeout: input.input_timeout.or(bound_input.input_timeout),
1235                            queue_policy: input.queue_policy.or(bound_input.queue_policy),
1236                        }))
1237                    }
1238                    None if optional_inputs.contains(&port_name) => Ok(None),
1239                    None => bail!(
1240                        "module input reference `_mod/{}` not found in module node inputs",
1241                        port_name,
1242                    ),
1243                }
1244            } else if inner_node_ids.contains(&source_str) {
1245                // Internal cross-reference: prefix with module_id
1246                Ok(Some(Input {
1247                    mapping: InputMapping::User(UserInputMapping {
1248                        source: format!("{module_id}.{source_str}").into(),
1249                        output: user_mapping.output.clone(),
1250                    }),
1251                    queue_size: input.queue_size,
1252                    input_timeout: input.input_timeout,
1253                    queue_policy: input.queue_policy,
1254                }))
1255            } else {
1256                // External reference — pass through unchanged
1257                Ok(Some(input.clone()))
1258            }
1259        }
1260    }
1261}
1262
1263/// Rewrite every input in `inputs` via [`rewrite_module_input`], dropping
1264/// entries whose optional module input wasn't provided. Used for both the
1265/// node-level `inputs` map and the operator/custom `config.inputs` /
1266/// `run_config.inputs` maps in Phase 1 of [`expand_module_node`].
1267fn rewrite_module_inputs_map(
1268    inputs: &BTreeMap<DataId, Input>,
1269    module_id: &str,
1270    module_inputs: &BTreeMap<DataId, Input>,
1271    inner_node_ids: &BTreeSet<String>,
1272    optional_inputs: &BTreeSet<String>,
1273) -> eyre::Result<BTreeMap<DataId, Input>> {
1274    let mut new_inputs = BTreeMap::new();
1275    for (input_id, input) in inputs {
1276        if let Some(new_input) = rewrite_module_input(
1277            input,
1278            module_id,
1279            module_inputs,
1280            inner_node_ids,
1281            optional_inputs,
1282        )? {
1283            new_inputs.insert(input_id.clone(), new_input);
1284        }
1285    }
1286    Ok(new_inputs)
1287}
1288
1289/// Rewrite inputs across all nodes that reference module outputs.
1290///
1291/// If node X has input `nav_stack/cmd_vel` and `nav_stack` was a module whose
1292/// output `cmd_vel` is produced by inner node `controller`, rewrite to
1293/// `nav_stack.controller/cmd_vel`. If the producer is an operator of a runtime
1294/// node, the rewritten output keeps the operator segment the runtime node
1295/// requires: `nav_stack.runtime/controller_op/cmd_vel`.
1296fn rewrite_external_refs(
1297    nodes: &mut [Node],
1298    output_maps: &BTreeMap<String, ModuleOutputMap>,
1299) -> eyre::Result<()> {
1300    if output_maps.is_empty() {
1301        return Ok(());
1302    }
1303
1304    for node in nodes.iter_mut() {
1305        rewrite_inputs_map(&mut node.inputs, output_maps, &node.id)?;
1306
1307        if let Some(ref mut operators) = node.operators {
1308            for op in &mut operators.operators {
1309                rewrite_inputs_map(&mut op.config.inputs, output_maps, &node.id)?;
1310            }
1311        }
1312        if let Some(ref mut operator) = node.operator {
1313            rewrite_inputs_map(&mut operator.config.inputs, output_maps, &node.id)?;
1314        }
1315    }
1316    Ok(())
1317}
1318
1319fn rewrite_inputs_map(
1320    inputs: &mut BTreeMap<DataId, Input>,
1321    output_maps: &BTreeMap<String, ModuleOutputMap>,
1322    node_id: &NodeId,
1323) -> eyre::Result<()> {
1324    let mut new_inputs = BTreeMap::new();
1325    for (input_id, input) in inputs.iter() {
1326        let new_input = match &input.mapping {
1327            InputMapping::User(user_mapping) => {
1328                let source_str = user_mapping.source.to_string();
1329                if let Some(omap) = output_maps.get(&source_str) {
1330                    let output_str = user_mapping.output.to_string();
1331                    if let Some(target) = omap.get(&output_str) {
1332                        Input {
1333                            mapping: InputMapping::User(target.clone()),
1334                            queue_size: input.queue_size,
1335                            input_timeout: input.input_timeout,
1336                            queue_policy: input.queue_policy,
1337                        }
1338                    } else {
1339                        bail!(
1340                            "node `{}` references `{}/{}` but module `{}` \
1341                             does not declare output `{}`",
1342                            node_id,
1343                            source_str,
1344                            output_str,
1345                            source_str,
1346                            output_str,
1347                        );
1348                    }
1349                } else {
1350                    input.clone()
1351                }
1352            }
1353            InputMapping::Timer { .. } | InputMapping::Logs(_) => input.clone(),
1354        };
1355        new_inputs.insert(input_id.clone(), new_input);
1356    }
1357    *inputs = new_inputs;
1358    Ok(())
1359}
1360
1361fn load_module_file(path: &Path) -> eyre::Result<ModuleFile> {
1362    use std::io::Read as _;
1363    // Bound the read itself, not just a `metadata().len()` pre-check: that
1364    // check reports 0 for FIFOs/character devices (e.g. `/dev/zero`) and is
1365    // racy against a file being appended to, so it would sail past the cap and
1366    // then `std::fs::read` unboundedly — exactly the "infinite files" DoS the
1367    // limit is meant to prevent. `take(MAX + 1)` caps the read regardless of
1368    // what the file claims its size is; if we hit the cap we reject. Mirrors
1369    // the manifest loader in `crate::manifest`.
1370    let file = std::fs::File::open(path)
1371        .with_context(|| format!("failed to read module file: {}", path.display()))?;
1372    let mut buf = Vec::new();
1373    file.take(MAX_MODULE_FILE_SIZE + 1)
1374        .read_to_end(&mut buf)
1375        .with_context(|| format!("failed to read module file: {}", path.display()))?;
1376    if buf.len() as u64 > MAX_MODULE_FILE_SIZE {
1377        bail!(
1378            "module file too large (limit {} bytes): {}",
1379            MAX_MODULE_FILE_SIZE,
1380            path.display()
1381        );
1382    }
1383    serde_yaml::from_slice(&buf)
1384        .with_context(|| format!("failed to parse module file: {}", path.display()))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390    use std::io::Write;
1391    use tempfile::TempDir;
1392
1393    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
1394        let path = dir.join(name);
1395        if let Some(parent) = path.parent() {
1396            std::fs::create_dir_all(parent).unwrap();
1397        }
1398        let mut f = std::fs::File::create(&path).unwrap();
1399        f.write_all(content.as_bytes()).unwrap();
1400        path
1401    }
1402
1403    fn parse_descriptor(yaml: &str) -> Descriptor {
1404        serde_yaml::from_str(yaml).unwrap()
1405    }
1406
1407    /// dora-rs/dora#2920: a dataflow-level setting that expansion drops is
1408    /// silently lost for every dataflow that uses modules. The completion
1409    /// policy decides whether the graph can ever end, so losing it turns a
1410    /// batch run into a hang.
1411    ///
1412    /// Both paths now clone the source descriptor, so this is a regression
1413    /// guard rather than the primary defense. The descriptor MUST still
1414    /// contain a module: without one, `expand_modules_with_boundaries`
1415    /// short-circuits before the expansion path this exercises.
1416    #[test]
1417    fn expand_preserves_exit_when_nodes_finish() {
1418        let tmp = TempDir::new().unwrap();
1419        let base = tmp.path();
1420        write_file(
1421            base,
1422            "echo_module.yml",
1423            r#"
1424module:
1425  name: echo
1426  inputs: [data_in]
1427  outputs: [data_out]
1428
1429nodes:
1430  - id: passthrough
1431    path: echo.py
1432    inputs:
1433      incoming: _mod/data_in
1434    outputs:
1435      - data_out
1436"#,
1437        );
1438        let descriptor = parse_descriptor(
1439            r#"
1440exit_when_nodes_finish: true
1441
1442nodes:
1443  - id: source
1444    path: source.py
1445    outputs:
1446      - number
1447
1448  - id: my_echo
1449    module: echo_module.yml
1450    inputs:
1451      data_in: source/number
1452"#,
1453        );
1454        assert_eq!(descriptor.exit_when_nodes_finish, Some(true));
1455        assert!(
1456            descriptor.nodes.iter().any(|n| n.module.is_some()),
1457            "precondition: without a module, expansion clones the whole \
1458             descriptor and this test would pass even if the rebuild \
1459             dropped the field"
1460        );
1461
1462        let expanded = expand_modules(&descriptor, base).unwrap();
1463        assert_eq!(
1464            expanded.exit_when_nodes_finish,
1465            Some(true),
1466            "expansion must carry the completion policy through, or a \
1467             module-using dataflow silently loses it and never ends"
1468        );
1469    }
1470
1471    /// Absent means absent: it must not become `Some(false)`, which
1472    /// would be indistinguishable from an explicit opt out and would
1473    /// start round-tripping into serialized output.
1474    #[test]
1475    fn expand_leaves_exit_when_nodes_finish_unset() {
1476        let tmp = TempDir::new().unwrap();
1477        let descriptor = parse_descriptor(
1478            "nodes:\n  \
1479               - id: worker\n    \
1480                 path: ./worker\n",
1481        );
1482        assert_eq!(descriptor.exit_when_nodes_finish, None);
1483        let expanded = expand_modules(&descriptor, tmp.path()).unwrap();
1484        assert_eq!(expanded.exit_when_nodes_finish, None);
1485    }
1486
1487    /// A module whose inner runtime node declares an operator with an id
1488    /// containing characters outside `[a-zA-Z0-9_./-]` must surface a clean
1489    /// descriptor error, not panic. `OperatorId` is unvalidated, so its id
1490    /// flows verbatim into the `<op_id>/<output>` qualified output id, which
1491    /// used to be built with `DataId::from` (panics) rather than a fallible
1492    /// parse.
1493    #[test]
1494    fn expand_rejects_invalid_operator_output_id_without_panicking() {
1495        let tmp = TempDir::new().unwrap();
1496        let base = tmp.path();
1497        write_file(
1498            base,
1499            "bad_module.yml",
1500            r#"
1501module:
1502  name: bad
1503  outputs: [data_out]
1504
1505nodes:
1506  - id: runtime_node
1507    operators:
1508      - id: "bad id"
1509        shared-library: op
1510        outputs:
1511          - data_out
1512"#,
1513        );
1514        let descriptor = parse_descriptor(
1515            r#"
1516nodes:
1517  - id: my_mod
1518    module: bad_module.yml
1519"#,
1520        );
1521        let err = expand_modules(&descriptor, base)
1522            .expect_err("an invalid operator output id must be a clean error, not a panic");
1523        let msg = err.to_string();
1524        assert!(msg.contains("invalid output id"), "unexpected error: {msg}");
1525    }
1526
1527    #[test]
1528    fn load_module_file_rejects_oversized_file() {
1529        let tmp = TempDir::new().unwrap();
1530        // A valid module prefix followed by padding that pushes the file just
1531        // over the cap. The read is bounded, so this is rejected before the
1532        // whole (potentially unbounded) file is pulled into memory.
1533        let mut content = String::from("module:\n  name: big\n  outputs: [x]\nnodes: []\n");
1534        content.push_str("# ");
1535        content.push_str(&"a".repeat((MAX_MODULE_FILE_SIZE + 16) as usize));
1536        let path = write_file(tmp.path(), "big_module.yml", &content);
1537
1538        let err = load_module_file(&path).unwrap_err().to_string();
1539        assert!(err.contains("too large"), "unexpected error: {err}");
1540    }
1541
1542    #[test]
1543    fn load_module_file_accepts_file_at_limit() {
1544        let tmp = TempDir::new().unwrap();
1545        // Exactly the cap (padding the comment out to MAX_MODULE_FILE_SIZE
1546        // bytes total) must still load — the `+ 1` in `take` is what makes the
1547        // boundary inclusive.
1548        let prefix = "module:\n  name: ok\n  outputs: [x]\nnodes: []\n# ";
1549        let pad = (MAX_MODULE_FILE_SIZE as usize) - prefix.len();
1550        let content = format!("{prefix}{}", "a".repeat(pad));
1551        assert_eq!(content.len() as u64, MAX_MODULE_FILE_SIZE);
1552        let path = write_file(tmp.path(), "ok_module.yml", &content);
1553
1554        assert!(load_module_file(&path).is_ok());
1555    }
1556
1557    #[test]
1558    fn expand_flat_passthrough() {
1559        let tmp = TempDir::new().unwrap();
1560        let base = tmp.path();
1561
1562        write_file(
1563            base,
1564            "echo_module.yml",
1565            r#"
1566module:
1567  name: echo
1568  inputs: [data_in]
1569  outputs: [data_out]
1570
1571nodes:
1572  - id: passthrough
1573    path: echo.py
1574    inputs:
1575      incoming: _mod/data_in
1576    outputs:
1577      - data_out
1578"#,
1579        );
1580
1581        let desc = parse_descriptor(
1582            r#"
1583nodes:
1584  - id: source
1585    path: source.py
1586    outputs:
1587      - number
1588
1589  - id: my_echo
1590    module: echo_module.yml
1591    inputs:
1592      data_in: source/number
1593
1594  - id: sink
1595    path: sink.py
1596    inputs:
1597      result: my_echo/data_out
1598"#,
1599        );
1600
1601        let expanded = expand_modules(&desc, base).unwrap();
1602
1603        assert_eq!(expanded.nodes.len(), 3);
1604        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
1605        assert!(names.contains(&"my_echo.passthrough".to_string()));
1606        assert!(!names.contains(&"my_echo".to_string()));
1607
1608        let passthrough = expanded
1609            .nodes
1610            .iter()
1611            .find(|n| n.id.to_string() == "my_echo.passthrough")
1612            .unwrap();
1613        let incoming = &passthrough.inputs[&DataId::from("incoming".to_string())];
1614        match &incoming.mapping {
1615            InputMapping::User(m) => {
1616                assert_eq!(m.source.to_string(), "source");
1617                assert_eq!(m.output.to_string(), "number");
1618            }
1619            _ => panic!("expected user mapping"),
1620        }
1621
1622        let sink = expanded
1623            .nodes
1624            .iter()
1625            .find(|n| n.id.to_string() == "sink")
1626            .unwrap();
1627        let result = &sink.inputs[&DataId::from("result".to_string())];
1628        match &result.mapping {
1629            InputMapping::User(m) => {
1630                assert_eq!(m.source.to_string(), "my_echo.passthrough");
1631                assert_eq!(m.output.to_string(), "data_out");
1632            }
1633            _ => panic!("expected user mapping"),
1634        }
1635    }
1636
1637    #[test]
1638    fn expand_internal_cross_ref() {
1639        let tmp = TempDir::new().unwrap();
1640        let base = tmp.path();
1641
1642        write_file(
1643            base,
1644            "pipeline_module.yml",
1645            r#"
1646module:
1647  name: pipeline
1648  inputs: [data_in]
1649  outputs: [data_out]
1650
1651nodes:
1652  - id: stage_a
1653    path: a.py
1654    inputs:
1655      raw: _mod/data_in
1656    outputs:
1657      - intermediate
1658
1659  - id: stage_b
1660    path: b.py
1661    inputs:
1662      intermediate: stage_a/intermediate
1663    outputs:
1664      - data_out
1665"#,
1666        );
1667
1668        let desc = parse_descriptor(
1669            r#"
1670nodes:
1671  - id: src
1672    path: src.py
1673    outputs: [val]
1674  - id: pipe
1675    module: pipeline_module.yml
1676    inputs:
1677      data_in: src/val
1678  - id: dst
1679    path: dst.py
1680    inputs:
1681      result: pipe/data_out
1682"#,
1683        );
1684
1685        let expanded = expand_modules(&desc, base).unwrap();
1686
1687        let stage_b = expanded
1688            .nodes
1689            .iter()
1690            .find(|n| n.id.to_string() == "pipe.stage_b")
1691            .unwrap();
1692        let inter = &stage_b.inputs[&DataId::from("intermediate".to_string())];
1693        match &inter.mapping {
1694            InputMapping::User(m) => {
1695                assert_eq!(m.source.to_string(), "pipe.stage_a");
1696                assert_eq!(m.output.to_string(), "intermediate");
1697            }
1698            _ => panic!("expected user mapping"),
1699        }
1700    }
1701
1702    #[test]
1703    fn expand_depth_limit() {
1704        let tmp = TempDir::new().unwrap();
1705        let base = tmp.path();
1706
1707        for i in 0..=MAX_MODULE_DEPTH {
1708            let next = if i < MAX_MODULE_DEPTH {
1709                format!(
1710                    "  - id: inner\n    module: level{}_module.yml\n    inputs:\n      x: _mod/x",
1711                    i + 1
1712                )
1713            } else {
1714                "  - id: inner\n    path: leaf.py\n    inputs:\n      x: _mod/x\n    outputs:\n      - y".to_string()
1715            };
1716
1717            write_file(
1718                base,
1719                &format!("level{i}_module.yml"),
1720                &format!(
1721                    r#"
1722module:
1723  name: level{i}
1724  inputs: [x]
1725  outputs: [y]
1726
1727nodes:
1728{next}
1729"#
1730                ),
1731            );
1732        }
1733
1734        let desc = parse_descriptor(
1735            r#"
1736nodes:
1737  - id: root
1738    module: level0_module.yml
1739    inputs:
1740      x: somewhere/val
1741"#,
1742        );
1743
1744        let result = expand_modules(&desc, base);
1745        assert!(result.is_err());
1746        assert!(
1747            result
1748                .unwrap_err()
1749                .to_string()
1750                .contains("nesting exceeds depth limit")
1751        );
1752    }
1753
1754    #[test]
1755    fn expand_circular_reference() {
1756        let tmp = TempDir::new().unwrap();
1757        let base = tmp.path();
1758
1759        write_file(
1760            base,
1761            "self_module.yml",
1762            r#"
1763module:
1764  name: self_ref
1765  inputs: [x]
1766  outputs: [y]
1767
1768nodes:
1769  - id: recurse
1770    module: self_module.yml
1771    inputs:
1772      x: _mod/x
1773"#,
1774        );
1775
1776        let desc = parse_descriptor(
1777            r#"
1778nodes:
1779  - id: top
1780    module: self_module.yml
1781    inputs:
1782      x: somewhere/val
1783"#,
1784        );
1785
1786        let result = expand_modules(&desc, base);
1787        assert!(result.is_err());
1788        let err_msg = result.unwrap_err().to_string();
1789        assert!(err_msg.contains("circular module reference"));
1790        // Feature 8: better error message with hint
1791        assert!(err_msg.contains("hint"));
1792    }
1793
1794    #[test]
1795    fn expand_missing_module_file() {
1796        let tmp = TempDir::new().unwrap();
1797        let base = tmp.path();
1798
1799        let desc = parse_descriptor(
1800            r#"
1801nodes:
1802  - id: broken
1803    module: nonexistent_module.yml
1804    inputs:
1805      x: somewhere/val
1806"#,
1807        );
1808
1809        let result = expand_modules(&desc, base);
1810        assert!(result.is_err());
1811        assert!(
1812            result
1813                .unwrap_err()
1814                .to_string()
1815                .contains("nonexistent_module.yml")
1816        );
1817    }
1818
1819    #[test]
1820    fn expand_undefined_input_port() {
1821        let tmp = TempDir::new().unwrap();
1822        let base = tmp.path();
1823
1824        write_file(
1825            base,
1826            "needs_input_module.yml",
1827            r#"
1828module:
1829  name: needs_input
1830  inputs: [required_port]
1831  outputs: [out]
1832
1833nodes:
1834  - id: inner
1835    path: inner.py
1836    inputs:
1837      x: _mod/required_port
1838    outputs:
1839      - out
1840"#,
1841        );
1842
1843        let desc = parse_descriptor(
1844            r#"
1845nodes:
1846  - id: mod_node
1847    module: needs_input_module.yml
1848    inputs:
1849      wrong_name: somewhere/val
1850"#,
1851        );
1852
1853        let result = expand_modules(&desc, base);
1854        assert!(result.is_err());
1855        let err_msg = result.unwrap_err().to_string();
1856        assert!(err_msg.contains("required_port"));
1857        // Feature 8: hint in error
1858        assert!(err_msg.contains("hint"));
1859    }
1860
1861    #[test]
1862    fn expand_rejects_duplicate_module_header_inputs() {
1863        let tmp = TempDir::new().unwrap();
1864        let base = tmp.path();
1865
1866        write_file(
1867            base,
1868            "dup_header_module.yml",
1869            r#"
1870module:
1871  name: dup_header
1872  inputs: [data, data]
1873  outputs: [out]
1874
1875nodes:
1876  - id: inner
1877    path: inner.py
1878    inputs:
1879      x: _mod/data
1880    outputs:
1881      - out
1882"#,
1883        );
1884
1885        let desc = parse_descriptor(
1886            r#"
1887nodes:
1888  - id: src
1889    path: src.py
1890    outputs: [val]
1891  - id: m
1892    module: dup_header_module.yml
1893    inputs:
1894      data: src/val
1895"#,
1896        );
1897
1898        let result = expand_modules(&desc, base);
1899        assert!(result.is_err());
1900        let msg = result.unwrap_err().to_string();
1901        assert!(msg.contains("duplicate"), "got: {msg}");
1902        assert!(msg.contains("inputs"), "got: {msg}");
1903        assert!(msg.contains("data"), "got: {msg}");
1904    }
1905
1906    #[test]
1907    fn expand_undefined_output_port() {
1908        let tmp = TempDir::new().unwrap();
1909        let base = tmp.path();
1910
1911        write_file(
1912            base,
1913            "bad_output_module.yml",
1914            r#"
1915module:
1916  name: bad_output
1917  inputs: []
1918  outputs: [nonexistent]
1919
1920nodes:
1921  - id: inner
1922    path: inner.py
1923    outputs:
1924      - something_else
1925"#,
1926        );
1927
1928        let desc = parse_descriptor(
1929            r#"
1930nodes:
1931  - id: mod_node
1932    module: bad_output_module.yml
1933"#,
1934        );
1935
1936        let result = expand_modules(&desc, base);
1937        assert!(result.is_err());
1938        assert!(result.unwrap_err().to_string().contains("nonexistent"));
1939    }
1940
1941    #[test]
1942    fn expand_no_modules_passthrough() {
1943        let desc = parse_descriptor(
1944            r#"
1945nodes:
1946  - id: a
1947    path: a.py
1948    outputs: [x]
1949  - id: b
1950    path: b.py
1951    inputs:
1952      x: a/x
1953"#,
1954        );
1955
1956        let tmp = TempDir::new().unwrap();
1957        let expanded = expand_modules(&desc, tmp.path()).unwrap();
1958        assert_eq!(expanded.nodes.len(), 2);
1959        assert_eq!(expanded.nodes[0].id.to_string(), "a");
1960        assert_eq!(expanded.nodes[1].id.to_string(), "b");
1961    }
1962
1963    #[test]
1964    fn expand_multiple_instances() {
1965        let tmp = TempDir::new().unwrap();
1966        let base = tmp.path();
1967
1968        write_file(
1969            base,
1970            "filter_module.yml",
1971            r#"
1972module:
1973  name: filter
1974  inputs: [raw]
1975  outputs: [filtered]
1976
1977nodes:
1978  - id: proc
1979    path: filter.py
1980    inputs:
1981      data: _mod/raw
1982    outputs:
1983      - filtered
1984"#,
1985        );
1986
1987        let desc = parse_descriptor(
1988            r#"
1989nodes:
1990  - id: cam1
1991    path: cam.py
1992    outputs: [frame]
1993  - id: cam2
1994    path: cam.py
1995    outputs: [frame]
1996  - id: filter1
1997    module: filter_module.yml
1998    inputs:
1999      raw: cam1/frame
2000  - id: filter2
2001    module: filter_module.yml
2002    inputs:
2003      raw: cam2/frame
2004  - id: merger
2005    path: merge.py
2006    inputs:
2007      a: filter1/filtered
2008      b: filter2/filtered
2009"#,
2010        );
2011
2012        let expanded = expand_modules(&desc, base).unwrap();
2013
2014        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
2015        assert!(names.contains(&"filter1.proc".to_string()));
2016        assert!(names.contains(&"filter2.proc".to_string()));
2017        assert_eq!(expanded.nodes.len(), 5);
2018
2019        let merger = expanded
2020            .nodes
2021            .iter()
2022            .find(|n| n.id.to_string() == "merger")
2023            .unwrap();
2024        let a_input = &merger.inputs[&DataId::from("a".to_string())];
2025        match &a_input.mapping {
2026            InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter1.proc"),
2027            _ => panic!("expected user mapping"),
2028        }
2029        let b_input = &merger.inputs[&DataId::from("b".to_string())];
2030        match &b_input.mapping {
2031            InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter2.proc"),
2032            _ => panic!("expected user mapping"),
2033        }
2034    }
2035
2036    #[test]
2037    fn expand_nested_modules() {
2038        let tmp = TempDir::new().unwrap();
2039        let base = tmp.path();
2040
2041        write_file(
2042            base,
2043            "inner_module.yml",
2044            r#"
2045module:
2046  name: inner
2047  inputs: [x]
2048  outputs: [y]
2049
2050nodes:
2051  - id: leaf
2052    path: leaf.py
2053    inputs:
2054      x: _mod/x
2055    outputs:
2056      - y
2057"#,
2058        );
2059
2060        write_file(
2061            base,
2062            "outer_module.yml",
2063            r#"
2064module:
2065  name: outer
2066  inputs: [a]
2067  outputs: [y]
2068
2069nodes:
2070  - id: nested
2071    module: inner_module.yml
2072    inputs:
2073      x: _mod/a
2074"#,
2075        );
2076
2077        let desc = parse_descriptor(
2078            r#"
2079nodes:
2080  - id: src
2081    path: src.py
2082    outputs: [val]
2083  - id: wrapper
2084    module: outer_module.yml
2085    inputs:
2086      a: src/val
2087"#,
2088        );
2089
2090        let expanded = expand_modules(&desc, base).unwrap();
2091        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
2092        assert!(names.contains(&"wrapper.nested.leaf".to_string()));
2093    }
2094
2095    // ---- Feature 3: optional inputs ----
2096
2097    #[test]
2098    fn expand_optional_input_provided() {
2099        let tmp = TempDir::new().unwrap();
2100        let base = tmp.path();
2101
2102        write_file(
2103            base,
2104            "opt_module.yml",
2105            r#"
2106module:
2107  name: opt
2108  inputs: [required]
2109  inputs_optional: [config]
2110  outputs: [out]
2111
2112nodes:
2113  - id: worker
2114    path: worker.py
2115    inputs:
2116      data: _mod/required
2117      cfg: _mod/config
2118    outputs:
2119      - out
2120"#,
2121        );
2122
2123        // Provide both required and optional
2124        let desc = parse_descriptor(
2125            r#"
2126nodes:
2127  - id: src
2128    path: src.py
2129    outputs: [data, cfg]
2130  - id: m
2131    module: opt_module.yml
2132    inputs:
2133      required: src/data
2134      config: src/cfg
2135"#,
2136        );
2137
2138        let expanded = expand_modules(&desc, base).unwrap();
2139        let worker = expanded
2140            .nodes
2141            .iter()
2142            .find(|n| n.id.to_string() == "m.worker")
2143            .unwrap();
2144        // Both inputs should be present
2145        assert_eq!(worker.inputs.len(), 2);
2146    }
2147
2148    #[test]
2149    fn expand_optional_input_omitted() {
2150        let tmp = TempDir::new().unwrap();
2151        let base = tmp.path();
2152
2153        write_file(
2154            base,
2155            "opt_module.yml",
2156            r#"
2157module:
2158  name: opt
2159  inputs: [required]
2160  inputs_optional: [config]
2161  outputs: [out]
2162
2163nodes:
2164  - id: worker
2165    path: worker.py
2166    inputs:
2167      data: _mod/required
2168      cfg: _mod/config
2169    outputs:
2170      - out
2171"#,
2172        );
2173
2174        // Only provide required, not optional
2175        let desc = parse_descriptor(
2176            r#"
2177nodes:
2178  - id: src
2179    path: src.py
2180    outputs: [data]
2181  - id: m
2182    module: opt_module.yml
2183    inputs:
2184      required: src/data
2185"#,
2186        );
2187
2188        let expanded = expand_modules(&desc, base).unwrap();
2189        let worker = expanded
2190            .nodes
2191            .iter()
2192            .find(|n| n.id.to_string() == "m.worker")
2193            .unwrap();
2194        // Only the required input should be present; optional was dropped
2195        assert_eq!(worker.inputs.len(), 1);
2196        assert!(
2197            worker
2198                .inputs
2199                .contains_key(&DataId::from("data".to_string()))
2200        );
2201    }
2202
2203    #[test]
2204    fn expand_rejects_module_input_not_declared_required_or_optional() {
2205        let tmp = TempDir::new().unwrap();
2206        let base = tmp.path();
2207
2208        write_file(
2209            base,
2210            "strict_inputs_module.yml",
2211            r#"
2212module:
2213  name: strict_inputs
2214  inputs: [required]
2215  inputs_optional: [config]
2216  outputs: [out]
2217
2218nodes:
2219  - id: worker
2220    path: worker.py
2221    inputs:
2222      data: _mod/required
2223    outputs:
2224      - out
2225"#,
2226        );
2227
2228        let desc = parse_descriptor(
2229            r#"
2230nodes:
2231  - id: src
2232    path: src.py
2233    outputs: [data, extra]
2234  - id: m
2235    module: strict_inputs_module.yml
2236    inputs:
2237      required: src/data
2238      typo: src/extra
2239"#,
2240        );
2241
2242        let result = expand_modules(&desc, base);
2243        assert!(result.is_err());
2244        let msg = result.unwrap_err().to_string();
2245        assert!(msg.contains("does not declare input"), "got: {msg}");
2246        assert!(msg.contains("typo"), "got: {msg}");
2247        assert!(msg.contains("required"), "got: {msg}");
2248        assert!(msg.contains("config"), "got: {msg}");
2249    }
2250
2251    // ---- Feature 4: params substitution ----
2252
2253    #[test]
2254    fn expand_params_in_env() {
2255        let tmp = TempDir::new().unwrap();
2256        let base = tmp.path();
2257
2258        write_file(
2259            base,
2260            "param_module.yml",
2261            r#"
2262module:
2263  name: parameterized
2264  inputs: [data]
2265  outputs: [out]
2266
2267nodes:
2268  - id: proc
2269    path: proc.py
2270    inputs:
2271      data: _mod/data
2272    outputs:
2273      - out
2274"#,
2275        );
2276
2277        let desc = parse_descriptor(
2278            r#"
2279nodes:
2280  - id: src
2281    path: src.py
2282    outputs: [val]
2283  - id: m
2284    module: param_module.yml
2285    inputs:
2286      data: src/val
2287    params:
2288      speed: "1.5"
2289      mode: turbo
2290"#,
2291        );
2292
2293        let expanded = expand_modules(&desc, base).unwrap();
2294        let proc = expanded
2295            .nodes
2296            .iter()
2297            .find(|n| n.id.to_string() == "m.proc")
2298            .unwrap();
2299        let env = proc.env.as_ref().unwrap();
2300        // Params are injected as PARAM_<UPPERCASE_KEY>
2301        assert_eq!(env["PARAM_SPEED"], EnvValue::String("1.5".to_string()));
2302        assert_eq!(env["PARAM_MODE"], EnvValue::String("turbo".to_string()));
2303    }
2304
2305    #[test]
2306    fn expand_params_in_args() {
2307        let tmp = TempDir::new().unwrap();
2308        let base = tmp.path();
2309
2310        write_file(
2311            base,
2312            "args_module.yml",
2313            r#"
2314module:
2315  name: with_args
2316  inputs: [data]
2317  outputs: [out]
2318
2319nodes:
2320  - id: proc
2321    path: proc.py
2322    inputs:
2323      data: _mod/data
2324    outputs:
2325      - out
2326    args: --speed ${_param.speed} --verbose
2327"#,
2328        );
2329
2330        let desc = parse_descriptor(
2331            r#"
2332nodes:
2333  - id: src
2334    path: src.py
2335    outputs: [val]
2336  - id: m
2337    module: args_module.yml
2338    inputs:
2339      data: src/val
2340    params:
2341      speed: "2.0"
2342"#,
2343        );
2344
2345        let expanded = expand_modules(&desc, base).unwrap();
2346        let proc = expanded
2347            .nodes
2348            .iter()
2349            .find(|n| n.id.to_string() == "m.proc")
2350            .unwrap();
2351        assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --verbose"));
2352    }
2353
2354    #[test]
2355    fn expand_params_in_documented_env_style_args() {
2356        let tmp = TempDir::new().unwrap();
2357        let base = tmp.path();
2358
2359        write_file(
2360            base,
2361            "args_module.yml",
2362            r#"
2363module:
2364  name: with_args
2365  inputs: [data]
2366  outputs: [out]
2367
2368nodes:
2369  - id: proc
2370    path: proc.py
2371    inputs:
2372      data: _mod/data
2373    outputs:
2374      - out
2375    args: --speed $PARAM_SPEED --mode $PARAM_MODE
2376"#,
2377        );
2378
2379        let desc = parse_descriptor(
2380            r#"
2381nodes:
2382  - id: src
2383    path: src.py
2384    outputs: [val]
2385  - id: m
2386    module: args_module.yml
2387    inputs:
2388      data: src/val
2389    params:
2390      speed: "2.0"
2391      mode: turbo
2392"#,
2393        );
2394
2395        let expanded = expand_modules(&desc, base).unwrap();
2396        let proc = expanded
2397            .nodes
2398            .iter()
2399            .find(|n| n.id.to_string() == "m.proc")
2400            .unwrap();
2401        assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --mode turbo"));
2402    }
2403
2404    #[test]
2405    fn expand_params_in_env_style_args_distinguishes_overlapping_keys() {
2406        // `speed` and `speed_limit` overlap as prefixes. The scanner takes the
2407        // maximal `[A-Za-z0-9_]` run as the key, so each token resolves to the
2408        // key it names exactly -- no ordering between the params is involved.
2409        // (The old implementation needed a longest-first sort here.)
2410        let tmp = TempDir::new().unwrap();
2411        let base = tmp.path();
2412
2413        write_file(
2414            base,
2415            "args_module.yml",
2416            r#"
2417module:
2418  name: with_args
2419  inputs: [data]
2420  outputs: [out]
2421
2422nodes:
2423  - id: proc
2424    path: proc.py
2425    inputs:
2426      data: _mod/data
2427    outputs:
2428      - out
2429    args: --short $PARAM_SPEED --long $PARAM_SPEED_LIMIT
2430"#,
2431        );
2432
2433        let desc = parse_descriptor(
2434            r#"
2435nodes:
2436  - id: src
2437    path: src.py
2438    outputs: [val]
2439  - id: m
2440    module: args_module.yml
2441    inputs:
2442      data: src/val
2443    params:
2444      speed: "2.0"
2445      speed_limit: "4.5"
2446"#,
2447        );
2448
2449        let expanded = expand_modules(&desc, base).unwrap();
2450        let proc = expanded
2451            .nodes
2452            .iter()
2453            .find(|n| n.id.to_string() == "m.proc")
2454            .unwrap();
2455        assert_eq!(proc.args.as_deref(), Some("--short 2.0 --long 4.5"));
2456    }
2457
2458    #[test]
2459    fn substitute_params_basic_and_unknown() {
2460        let params = BTreeMap::from([
2461            ("speed".to_string(), "2.0".to_string()),
2462            ("name".to_string(), "robot".to_string()),
2463        ]);
2464        assert_eq!(
2465            substitute_params_in_str("--speed ${_param.speed} --name ${_param.name}", &params),
2466            "--speed 2.0 --name robot"
2467        );
2468        // Multiple occurrences of the same key are all replaced.
2469        assert_eq!(
2470            substitute_params_in_str("${_param.speed}/${_param.speed}", &params),
2471            "2.0/2.0"
2472        );
2473        // An unknown key is left verbatim, not dropped.
2474        assert_eq!(
2475            substitute_params_in_str("${_param.missing}", &params),
2476            "${_param.missing}"
2477        );
2478        // A dangling prefix without a closing brace is emitted literally.
2479        assert_eq!(
2480            substitute_params_in_str("prefix ${_param.speed", &params),
2481            "prefix ${_param.speed"
2482        );
2483    }
2484
2485    #[test]
2486    fn substitute_params_env_style_requires_an_identifier_boundary() {
2487        // `$PARAM_<KEY>` has no terminator, so the key must run to the end of
2488        // the shell-identifier characters. A declared param that is only a
2489        // *prefix* of the token must not match (#2901).
2490        let params = BTreeMap::from([("speed".to_string(), "2.0".to_string())]);
2491        assert_eq!(
2492            substitute_params_in_str("--flag $PARAM_SPEED_LIMIT", &params),
2493            "--flag $PARAM_SPEED_LIMIT"
2494        );
2495        // The exact token still substitutes, and a non-identifier character
2496        // terminates it.
2497        assert_eq!(
2498            substitute_params_in_str("--flag $PARAM_SPEED --x", &params),
2499            "--flag 2.0 --x"
2500        );
2501        assert_eq!(
2502            substitute_params_in_str("$PARAM_SPEED,$PARAM_SPEED", &params),
2503            "2.0,2.0"
2504        );
2505        // A bare `$` and an unknown token are both emitted verbatim.
2506        assert_eq!(
2507            substitute_params_in_str("cost $5 $PARAM_MISSING", &params),
2508            "cost $5 $PARAM_MISSING"
2509        );
2510    }
2511
2512    #[test]
2513    fn substitute_params_env_style_is_not_re_expanded() {
2514        // A value that itself looks like a token is inserted verbatim, never
2515        // re-scanned — the property the single-pass scanner exists for.
2516        let params = BTreeMap::from([
2517            ("a".to_string(), "$PARAM_B".to_string()),
2518            ("b".to_string(), "x".to_string()),
2519        ]);
2520        assert_eq!(substitute_params_in_str("$PARAM_A", &params), "$PARAM_B");
2521    }
2522
2523    #[test]
2524    fn substitute_params_is_order_independent_and_non_transitive() {
2525        // A parameter value that itself looks like a `${_param.…}` token must be
2526        // inserted verbatim, never re-expanded — and the result must not depend
2527        // on `BTreeMap` key ordering. With the old chained-`replace` approach,
2528        // `a`'s value `${_param.b}` was expanded to `x` because `a` sorts first.
2529        let params = BTreeMap::from([
2530            ("a".to_string(), "${_param.b}".to_string()),
2531            ("b".to_string(), "x".to_string()),
2532        ]);
2533        assert_eq!(
2534            substitute_params_in_str("${_param.a}", &params),
2535            "${_param.b}"
2536        );
2537        assert_eq!(substitute_params_in_str("${_param.b}", &params), "x");
2538    }
2539
2540    #[test]
2541    fn expand_module_node_env_propagates_to_inner_nodes() {
2542        let tmp = TempDir::new().unwrap();
2543        let base = tmp.path();
2544
2545        write_file(
2546            base,
2547            "env_module.yml",
2548            r#"
2549module:
2550  name: env_module
2551  inputs: [data]
2552  outputs: [out]
2553
2554nodes:
2555  - id: worker
2556    path: worker.py
2557    env:
2558      INNER_ONLY: from-inner
2559      SHARED: inner
2560    inputs:
2561      data: _mod/data
2562    outputs:
2563      - out
2564"#,
2565        );
2566
2567        let desc = parse_descriptor(
2568            r#"
2569nodes:
2570  - id: src
2571    path: src.py
2572    outputs: [val]
2573  - id: m
2574    module: env_module.yml
2575    env:
2576      WRAPPER_ONLY: from-wrapper
2577      SHARED: wrapper
2578    inputs:
2579      data: src/val
2580"#,
2581        );
2582
2583        let expanded = expand_modules(&desc, base).unwrap();
2584        let worker = expanded
2585            .nodes
2586            .iter()
2587            .find(|n| n.id.to_string() == "m.worker")
2588            .unwrap();
2589        let env = worker.env.as_ref().unwrap();
2590        assert_eq!(
2591            env["INNER_ONLY"],
2592            EnvValue::String("from-inner".to_string())
2593        );
2594        assert_eq!(
2595            env["WRAPPER_ONLY"],
2596            EnvValue::String("from-wrapper".to_string())
2597        );
2598        // The inner node's own `env` wins over the module node's, matching
2599        // `merge_env` ("per-node entries override global ones") and `deploy`.
2600        assert_eq!(env["SHARED"], EnvValue::String("inner".to_string()));
2601    }
2602
2603    #[test]
2604    fn expand_module_node_with_empty_env_leaves_inner_env_unset() {
2605        let tmp = TempDir::new().unwrap();
2606        let base = tmp.path();
2607
2608        write_file(
2609            base,
2610            "empty_env_module.yml",
2611            r#"
2612module:
2613  name: empty_env_module
2614  inputs: [data]
2615  outputs: [out]
2616
2617nodes:
2618  - id: worker
2619    path: worker.py
2620    inputs:
2621      data: _mod/data
2622    outputs:
2623      - out
2624"#,
2625        );
2626
2627        let desc = parse_descriptor(
2628            r#"
2629nodes:
2630  - id: src
2631    path: src.py
2632    outputs: [val]
2633  - id: m
2634    module: empty_env_module.yml
2635    env: {}
2636    inputs:
2637      data: src/val
2638"#,
2639        );
2640
2641        let expanded = expand_modules(&desc, base).unwrap();
2642        let worker = expanded
2643            .nodes
2644            .iter()
2645            .find(|n| n.id.to_string() == "m.worker")
2646            .unwrap();
2647        assert_eq!(worker.env, None);
2648    }
2649
2650    #[test]
2651    fn expand_outer_params_reach_nested_module_inner_nodes_as_env() {
2652        let tmp = TempDir::new().unwrap();
2653        let base = tmp.path();
2654
2655        write_file(
2656            base,
2657            "inner.yml",
2658            r#"
2659module:
2660  name: inner
2661  inputs: [data]
2662  outputs: [out]
2663
2664nodes:
2665  - id: worker
2666    path: worker.py
2667    inputs:
2668      data: _mod/data
2669    outputs:
2670      - out
2671"#,
2672        );
2673
2674        write_file(
2675            base,
2676            "outer.yml",
2677            r#"
2678module:
2679  name: outer
2680  inputs: [data]
2681  outputs: [out]
2682
2683nodes:
2684  - id: inner
2685    module: inner.yml
2686    inputs:
2687      data: _mod/data
2688"#,
2689        );
2690
2691        let desc = parse_descriptor(
2692            r#"
2693nodes:
2694  - id: src
2695    path: src.py
2696    outputs: [val]
2697  - id: outer
2698    module: outer.yml
2699    inputs:
2700      data: src/val
2701    params:
2702      speed: "2.0"
2703"#,
2704        );
2705
2706        let expanded = expand_modules(&desc, base).unwrap();
2707        let worker = expanded
2708            .nodes
2709            .iter()
2710            .find(|n| n.id.to_string() == "outer.inner.worker")
2711            .unwrap();
2712        let env = worker.env.as_ref().unwrap();
2713        assert_eq!(env["PARAM_SPEED"], EnvValue::String("2.0".to_string()));
2714    }
2715
2716    // ---- Feature 5: module-level build ----
2717
2718    /// Regression for #3258 (defect 1): a `build:` set on the module node itself
2719    /// (not the module file header) is accepted and propagates into the expanded
2720    /// leaf nodes, matching the documented contract and the nested behavior.
2721    #[test]
2722    fn expand_top_level_module_build_propagated() {
2723        let tmp = TempDir::new().unwrap();
2724        let base = tmp.path();
2725
2726        write_file(
2727            base,
2728            "leaf_module.yml",
2729            r#"
2730module:
2731  name: leaf
2732  inputs: [data]
2733  outputs: [out]
2734
2735nodes:
2736  - id: proc
2737    path: proc.py
2738    inputs:
2739      data: _mod/data
2740    outputs:
2741      - out
2742    build: python setup.py build
2743"#,
2744        );
2745
2746        let desc = parse_descriptor(
2747            r#"
2748nodes:
2749  - id: src
2750    path: src.py
2751    outputs: [val]
2752  - id: m
2753    module: leaf_module.yml
2754    build: pip install foo
2755    inputs:
2756      data: src/val
2757"#,
2758        );
2759
2760        let expanded = expand_modules(&desc, base).unwrap();
2761        let proc = expanded
2762            .nodes
2763            .iter()
2764            .find(|n| n.id.to_string() == "m.proc")
2765            .unwrap();
2766        let build = proc.build.as_deref().unwrap();
2767        assert!(
2768            build.starts_with("pip install foo"),
2769            "the module node's own build must be prepended; got: {build}"
2770        );
2771        assert!(build.contains("python setup.py build"), "{build}");
2772    }
2773
2774    /// Regression for #3258 (defect 2): a per-node runtime field that has no
2775    /// meaning on a module node (here `outputs`) is rejected at expansion time
2776    /// rather than silently dropped, at every nesting level.
2777    #[test]
2778    fn nested_module_node_rejects_disallowed_field() {
2779        let tmp = TempDir::new().unwrap();
2780        let base = tmp.path();
2781
2782        write_file(
2783            base,
2784            "leaf_module.yml",
2785            r#"
2786module:
2787  name: leaf
2788  inputs: [x]
2789  outputs: [y]
2790
2791nodes:
2792  - id: worker
2793    path: worker.py
2794    inputs:
2795      x: _mod/x
2796    outputs:
2797      - y
2798"#,
2799        );
2800
2801        // `outputs` on the nested module node is not part of the module
2802        // whitelist; expansion must reject it.
2803        write_file(
2804            base,
2805            "outer_module.yml",
2806            r#"
2807module:
2808  name: outer
2809  inputs: [x]
2810  outputs: [y]
2811
2812nodes:
2813  - id: inner
2814    module: leaf_module.yml
2815    inputs:
2816      x: _mod/x
2817    outputs:
2818      - y
2819"#,
2820        );
2821
2822        let desc = parse_descriptor(
2823            r#"
2824nodes:
2825  - id: src
2826    path: src.py
2827    outputs: [val]
2828  - id: top
2829    module: outer_module.yml
2830    inputs:
2831      x: src/val
2832"#,
2833        );
2834
2835        let error = format!("{:#}", expand_modules(&desc, base).unwrap_err());
2836        assert!(
2837            error.contains("outputs") && error.contains("Module"),
2838            "nested module node with `outputs` should be rejected; got: {error}"
2839        );
2840    }
2841
2842    #[test]
2843    fn expand_module_build_prepended() {
2844        let tmp = TempDir::new().unwrap();
2845        let base = tmp.path();
2846
2847        write_file(
2848            base,
2849            "build_module.yml",
2850            r#"
2851module:
2852  name: buildable
2853  inputs: [data]
2854  outputs: [out]
2855
2856build: pip install -r requirements.txt
2857
2858nodes:
2859  - id: proc
2860    path: proc.py
2861    inputs:
2862      data: _mod/data
2863    outputs:
2864      - out
2865    build: python setup.py build
2866"#,
2867        );
2868
2869        let desc = parse_descriptor(
2870            r#"
2871nodes:
2872  - id: src
2873    path: src.py
2874    outputs: [val]
2875  - id: m
2876    module: build_module.yml
2877    inputs:
2878      data: src/val
2879"#,
2880        );
2881
2882        let expanded = expand_modules(&desc, base).unwrap();
2883        let proc = expanded
2884            .nodes
2885            .iter()
2886            .find(|n| n.id.to_string() == "m.proc")
2887            .unwrap();
2888        let build = proc.build.as_deref().unwrap();
2889        assert!(build.starts_with("pip install -r requirements.txt"));
2890        assert!(build.contains("python setup.py build"));
2891    }
2892
2893    #[test]
2894    fn expand_module_build_no_inner_build() {
2895        let tmp = TempDir::new().unwrap();
2896        let base = tmp.path();
2897
2898        write_file(
2899            base,
2900            "build_only_module.yml",
2901            r#"
2902module:
2903  name: build_only
2904  inputs: [data]
2905  outputs: [out]
2906
2907build: make all
2908
2909nodes:
2910  - id: proc
2911    path: proc.py
2912    inputs:
2913      data: _mod/data
2914    outputs:
2915      - out
2916"#,
2917        );
2918
2919        let desc = parse_descriptor(
2920            r#"
2921nodes:
2922  - id: src
2923    path: src.py
2924    outputs: [val]
2925  - id: m
2926    module: build_only_module.yml
2927    inputs:
2928      data: src/val
2929"#,
2930        );
2931
2932        let expanded = expand_modules(&desc, base).unwrap();
2933        let proc = expanded
2934            .nodes
2935            .iter()
2936            .find(|n| n.id.to_string() == "m.proc")
2937            .unwrap();
2938        assert_eq!(proc.build.as_deref(), Some("make all"));
2939    }
2940
2941    /// A module-level `build:` must reach `git:`/`hub:`-sourced inner nodes,
2942    /// not just `path:`-sourced ones. Module expansion runs before git/hub
2943    /// source resolution, so these nodes still have `path == None` at this
2944    /// point; the build must be keyed off the node kind, not `node.path`
2945    /// (#3296).
2946    #[test]
2947    fn expand_module_build_prepended_to_git_and_hub_inner_nodes() {
2948        let tmp = TempDir::new().unwrap();
2949        let base = tmp.path();
2950
2951        write_file(
2952            base,
2953            "git_hub_module.yml",
2954            r#"
2955module:
2956  name: git_hub
2957  outputs: [from_git, from_hub]
2958
2959build: pip install -r requirements.txt
2960
2961nodes:
2962  - id: worker
2963    git: https://github.com/example/worker.git
2964    outputs:
2965      - from_git
2966    build: cargo build --release
2967  - id: fetched
2968    hub: example/fetched
2969    outputs:
2970      - from_hub
2971"#,
2972        );
2973
2974        let desc = parse_descriptor(
2975            r#"
2976nodes:
2977  - id: m
2978    module: git_hub_module.yml
2979"#,
2980        );
2981
2982        let expanded = expand_modules(&desc, base).unwrap();
2983
2984        // git-sourced inner node: module build prepended before its own build.
2985        let worker = expanded
2986            .nodes
2987            .iter()
2988            .find(|n| n.id.to_string() == "m.worker")
2989            .unwrap();
2990        assert_eq!(
2991            worker.build.as_deref(),
2992            Some("pip install -r requirements.txt\ncargo build --release"),
2993        );
2994
2995        // hub-sourced inner node with no own build: module build is set.
2996        let fetched = expanded
2997            .nodes
2998            .iter()
2999            .find(|n| n.id.to_string() == "m.fetched")
3000            .unwrap();
3001        assert_eq!(
3002            fetched.build.as_deref(),
3003            Some("pip install -r requirements.txt"),
3004        );
3005    }
3006
3007    /// A module-level `build:` must not make an operator inner node
3008    /// unresolvable. The expansion-only tests above stop at `expand_modules`;
3009    /// the field classifier runs at *resolution*, so the regression this pins
3010    /// lives in the seam between the two (#3070).
3011    #[test]
3012    fn module_build_keeps_operator_inner_nodes_resolvable() {
3013        let tmp = TempDir::new().unwrap();
3014        let base = tmp.path();
3015
3016        write_file(
3017            base,
3018            "kinds_module.yml",
3019            r#"
3020build: pip install shared
3021
3022module:
3023  name: kinds
3024  inputs: [data]
3025  outputs: [from_runtime, from_operator]
3026
3027nodes:
3028  - id: runtime
3029    operators:
3030      - id: proc
3031        shared-library: proc
3032        inputs:
3033          data: _mod/data
3034        outputs:
3035          - from_runtime
3036
3037  - id: single
3038    operator:
3039      python: single.py
3040      inputs:
3041        data: _mod/data
3042      outputs:
3043        - from_operator
3044"#,
3045        );
3046
3047        let desc = parse_descriptor(
3048            r#"
3049nodes:
3050  - id: src
3051    path: src.py
3052    outputs: [val]
3053  - id: m
3054    module: kinds_module.yml
3055    inputs:
3056      data: src/val
3057"#,
3058        );
3059
3060        let expanded = expand_modules(&desc, base).unwrap();
3061        // The module build must land in the operator configs, which is where
3062        // `build/mod.rs` reads it from for runtime nodes...
3063        let runtime = expanded
3064            .nodes
3065            .iter()
3066            .find(|n| n.id.to_string() == "m.runtime")
3067            .unwrap();
3068        assert_eq!(
3069            runtime.operators.as_ref().unwrap().operators[0]
3070                .config
3071                .build
3072                .as_deref(),
3073            Some("pip install shared"),
3074        );
3075        // ...and must NOT land on the node itself, where no kind but
3076        // `path:`/`module:` consumes it.
3077        assert_eq!(runtime.build, None);
3078
3079        crate::descriptor::DescriptorExt::resolve_aliases_and_set_defaults(&expanded)
3080            .expect("a module-level build must not make operator nodes unresolvable");
3081    }
3082
3083    #[test]
3084    fn expand_module_build_prepended_to_operator_and_custom_builds() {
3085        let tmp = TempDir::new().unwrap();
3086        let base = tmp.path();
3087
3088        write_file(
3089            base,
3090            "inner_kind_build_module.yml",
3091            r#"
3092module:
3093  name: inner_kind_builds
3094  inputs: [data]
3095  outputs: [from_runtime, from_operator]
3096
3097build: pip install shared
3098
3099nodes:
3100  - id: runtime
3101    operators:
3102      - id: proc
3103        shared-library: libproc.so
3104        build: make proc
3105        inputs:
3106          data: _mod/data
3107        outputs:
3108          - from_runtime
3109
3110  - id: single
3111    operator:
3112      python: single.py
3113      build: pip install single
3114      inputs:
3115        data: _mod/data
3116      outputs:
3117        - from_operator
3118"#,
3119        );
3120
3121        let desc = parse_descriptor(
3122            r#"
3123nodes:
3124  - id: src
3125    path: src.py
3126    outputs: [val]
3127  - id: m
3128    module: inner_kind_build_module.yml
3129    inputs:
3130      data: src/val
3131"#,
3132        );
3133
3134        let expanded = expand_modules(&desc, base).unwrap();
3135
3136        let runtime = expanded
3137            .nodes
3138            .iter()
3139            .find(|n| n.id.to_string() == "m.runtime")
3140            .unwrap();
3141        let runtime_build = runtime.operators.as_ref().unwrap().operators[0]
3142            .config
3143            .build
3144            .as_deref()
3145            .unwrap();
3146        assert_eq!(runtime_build, "pip install shared\nmake proc");
3147
3148        let single = expanded
3149            .nodes
3150            .iter()
3151            .find(|n| n.id.to_string() == "m.single")
3152            .unwrap();
3153        let single_build = single.operator.as_ref().unwrap().config.build.as_deref();
3154        assert_eq!(single_build, Some("pip install shared\npip install single"));
3155    }
3156
3157    // ---- Feature 1: boundaries metadata ----
3158
3159    #[test]
3160    fn expand_returns_boundaries() {
3161        let tmp = TempDir::new().unwrap();
3162        let base = tmp.path();
3163
3164        write_file(
3165            base,
3166            "simple_module.yml",
3167            r#"
3168module:
3169  name: simple
3170  inputs: [x]
3171  outputs: [y]
3172
3173nodes:
3174  - id: a
3175    path: a.py
3176    inputs:
3177      x: _mod/x
3178    outputs: [mid]
3179  - id: b
3180    path: b.py
3181    inputs:
3182      mid: a/mid
3183    outputs: [y]
3184"#,
3185        );
3186
3187        let desc = parse_descriptor(
3188            r#"
3189nodes:
3190  - id: src
3191    path: src.py
3192    outputs: [val]
3193  - id: mod1
3194    module: simple_module.yml
3195    inputs:
3196      x: src/val
3197"#,
3198        );
3199
3200        let result = expand_modules_with_boundaries(&desc, base).unwrap();
3201        assert!(result.boundaries.modules.contains_key("mod1"));
3202        let members = &result.boundaries.modules["mod1"];
3203        assert!(members.contains(&"mod1.a".to_string()));
3204        assert!(members.contains(&"mod1.b".to_string()));
3205    }
3206
3207    // ---- Feature 9: check_module_file ----
3208
3209    #[test]
3210    fn check_module_file_valid() {
3211        let tmp = TempDir::new().unwrap();
3212        let path = write_file(
3213            tmp.path(),
3214            "valid_module.yml",
3215            r#"
3216module:
3217  name: valid
3218  inputs: [x]
3219  outputs: [y]
3220
3221nodes:
3222  - id: proc
3223    path: proc.py
3224    inputs:
3225      x: _mod/x
3226    outputs:
3227      - y
3228"#,
3229        );
3230
3231        check_module_file(&path).unwrap();
3232    }
3233
3234    #[test]
3235    fn check_module_file_bad_mod_ref() {
3236        let tmp = TempDir::new().unwrap();
3237        let path = write_file(
3238            tmp.path(),
3239            "bad_ref_module.yml",
3240            r#"
3241module:
3242  name: bad_ref
3243  inputs: [x]
3244  outputs: [y]
3245
3246nodes:
3247  - id: proc
3248    path: proc.py
3249    inputs:
3250      x: _mod/nonexistent
3251    outputs:
3252      - y
3253"#,
3254        );
3255
3256        let result = check_module_file(&path);
3257        assert!(result.is_err());
3258        assert!(result.unwrap_err().to_string().contains("nonexistent"));
3259    }
3260
3261    #[test]
3262    fn check_module_file_rejects_duplicate_header_ports() {
3263        let tmp = TempDir::new().unwrap();
3264
3265        let duplicate_inputs = write_file(
3266            tmp.path(),
3267            "duplicate_inputs.yml",
3268            r#"
3269module:
3270  name: duplicate_inputs
3271  inputs: [data, data]
3272  outputs: [out]
3273
3274nodes:
3275  - id: worker
3276    path: worker.py
3277    inputs:
3278      x: _mod/data
3279    outputs:
3280      - out
3281"#,
3282        );
3283        let msg = check_module_file(&duplicate_inputs)
3284            .unwrap_err()
3285            .to_string();
3286        assert!(msg.contains("duplicate"), "got: {msg}");
3287        assert!(msg.contains("inputs"), "got: {msg}");
3288        assert!(msg.contains("data"), "got: {msg}");
3289
3290        let duplicate_optional = write_file(
3291            tmp.path(),
3292            "duplicate_optional.yml",
3293            r#"
3294module:
3295  name: duplicate_optional
3296  inputs: [data]
3297  inputs_optional: [cfg, cfg]
3298  outputs: [out]
3299
3300nodes:
3301  - id: worker
3302    path: worker.py
3303    inputs:
3304      x: _mod/data
3305    outputs:
3306      - out
3307"#,
3308        );
3309        let msg = check_module_file(&duplicate_optional)
3310            .unwrap_err()
3311            .to_string();
3312        assert!(msg.contains("duplicate"), "got: {msg}");
3313        assert!(msg.contains("inputs_optional"), "got: {msg}");
3314        assert!(msg.contains("cfg"), "got: {msg}");
3315
3316        let duplicate_outputs = write_file(
3317            tmp.path(),
3318            "duplicate_outputs.yml",
3319            r#"
3320module:
3321  name: duplicate_outputs
3322  inputs: [data]
3323  outputs: [out, out]
3324
3325nodes:
3326  - id: worker
3327    path: worker.py
3328    inputs:
3329      x: _mod/data
3330    outputs:
3331      - out
3332"#,
3333        );
3334        let msg = check_module_file(&duplicate_outputs)
3335            .unwrap_err()
3336            .to_string();
3337        assert!(msg.contains("duplicate"), "got: {msg}");
3338        assert!(msg.contains("outputs"), "got: {msg}");
3339        assert!(msg.contains("out"), "got: {msg}");
3340    }
3341
3342    #[test]
3343    fn check_module_file_bad_output() {
3344        let tmp = TempDir::new().unwrap();
3345        let path = write_file(
3346            tmp.path(),
3347            "bad_out_module.yml",
3348            r#"
3349module:
3350  name: bad_out
3351  inputs: []
3352  outputs: [missing]
3353
3354nodes:
3355  - id: proc
3356    path: proc.py
3357    outputs:
3358      - other
3359"#,
3360        );
3361
3362        let result = check_module_file(&path);
3363        assert!(result.is_err());
3364        assert!(result.unwrap_err().to_string().contains("missing"));
3365    }
3366
3367    #[test]
3368    fn check_module_file_rejects_ambiguous_declared_output() {
3369        let tmp = TempDir::new().unwrap();
3370        let path = write_file(
3371            tmp.path(),
3372            "ambiguous_output.yml",
3373            r#"
3374module:
3375  name: ambiguous
3376  inputs: []
3377  outputs: [out]
3378
3379nodes:
3380  - id: first
3381    path: first.py
3382    outputs:
3383      - out
3384  - id: second
3385    path: second.py
3386    outputs:
3387      - out
3388"#,
3389        );
3390
3391        let result = check_module_file(&path);
3392        assert!(result.is_err());
3393        let msg = result.unwrap_err().to_string();
3394        assert!(msg.contains("out"), "got: {msg}");
3395        assert!(msg.contains("multiple"), "got: {msg}");
3396        assert!(msg.contains("first"), "got: {msg}");
3397        assert!(msg.contains("second"), "got: {msg}");
3398    }
3399
3400    #[test]
3401    fn check_module_file_rejects_unknown_module_header_field() {
3402        let tmp = TempDir::new().unwrap();
3403        let path = write_file(
3404            tmp.path(),
3405            "unknown_header_field.yml",
3406            r#"
3407module:
3408  name: bad
3409  inputz: [data]
3410  outputs: [out]
3411
3412nodes:
3413  - id: worker
3414    path: worker.py
3415    outputs:
3416      - out
3417"#,
3418        );
3419
3420        let result = check_module_file(&path);
3421        assert!(result.is_err());
3422        let msg = format!("{:?}", result.unwrap_err());
3423        assert!(msg.contains("inputz"), "got: {msg}");
3424        assert!(msg.contains("unknown field"), "got: {msg}");
3425    }
3426
3427    #[test]
3428    fn check_module_file_rejects_duplicate_inner_node_ids() {
3429        let tmp = TempDir::new().unwrap();
3430        let path = write_file(
3431            tmp.path(),
3432            "duplicate_inner_nodes.yml",
3433            r#"
3434module:
3435  name: duplicate_inner_nodes
3436  inputs: []
3437  outputs: [y]
3438
3439nodes:
3440  - id: proc
3441    path: a.py
3442    outputs:
3443      - y
3444  - id: proc
3445    path: b.py
3446    outputs:
3447      - z
3448"#,
3449        );
3450
3451        let err = check_module_file(&path).unwrap_err().to_string();
3452        assert!(err.contains("duplicate node ID"), "{err}");
3453        assert!(err.contains("proc"), "{err}");
3454    }
3455
3456    #[test]
3457    fn check_module_file_rejects_invalid_internal_wiring() {
3458        let tmp = TempDir::new().unwrap();
3459        let path = write_file(
3460            tmp.path(),
3461            "bad_internal_wiring.yml",
3462            r#"
3463module:
3464  name: bad_internal_wiring
3465  inputs: []
3466  outputs: [y]
3467
3468nodes:
3469  - id: preprocessor
3470    path: preprocessor.py
3471    outputs:
3472      - cleaned
3473  - id: producer
3474    path: producer.py
3475    inputs:
3476      data: preprocessor/cleaned
3477    outputs:
3478      - y
3479  - id: valid_consumer
3480    path: valid_consumer.py
3481    inputs:
3482      data: producer/y
3483    outputs:
3484      - z
3485  - id: consumer
3486    path: consumer.py
3487    inputs:
3488      data: producer/missing
3489  - id: independent
3490    path: independent.py
3491    outputs:
3492      - side
3493"#,
3494        );
3495
3496        let err = check_module_file(&path).unwrap_err().to_string();
3497        assert!(err.contains("producer/missing"), "{err}");
3498    }
3499
3500    #[test]
3501    fn check_module_file_rejects_nested_module_missing_required_input() {
3502        let tmp = TempDir::new().unwrap();
3503
3504        write_file(
3505            tmp.path(),
3506            "leaf.yml",
3507            r#"
3508module:
3509  name: leaf
3510  inputs: [data]
3511  outputs: [out]
3512
3513nodes:
3514  - id: worker
3515    path: worker.py
3516    inputs:
3517      x: _mod/data
3518    outputs:
3519      - out
3520"#,
3521        );
3522
3523        let path = write_file(
3524            tmp.path(),
3525            "outer.yml",
3526            r#"
3527module:
3528  name: outer
3529  inputs: []
3530  outputs: [out]
3531
3532nodes:
3533  - id: nested
3534    module: leaf.yml
3535"#,
3536        );
3537
3538        let result = check_module_file(&path);
3539        assert!(result.is_err());
3540        let msg = result.unwrap_err().to_string();
3541        assert!(msg.contains("leaf"), "got: {msg}");
3542        assert!(msg.contains("data"), "got: {msg}");
3543        assert!(msg.contains("nested"), "got: {msg}");
3544    }
3545
3546    #[test]
3547    fn check_module_file_rejects_invalid_nested_module() {
3548        let tmp = TempDir::new().unwrap();
3549
3550        write_file(
3551            tmp.path(),
3552            "leaf.yml",
3553            r#"
3554module:
3555  name: leaf
3556  inputs: []
3557  outputs: [out]
3558
3559nodes:
3560  - id: worker
3561    path: worker.py
3562    outputs:
3563      - other
3564"#,
3565        );
3566
3567        let path = write_file(
3568            tmp.path(),
3569            "outer.yml",
3570            r#"
3571module:
3572  name: outer
3573  inputs: []
3574  outputs: [out]
3575
3576nodes:
3577  - id: nested
3578    module: leaf.yml
3579"#,
3580        );
3581
3582        let result = check_module_file(&path);
3583        assert!(result.is_err());
3584        let msg = format!("{:#}", result.unwrap_err());
3585        assert!(msg.contains("leaf"), "got: {msg}");
3586        assert!(msg.contains("out"), "got: {msg}");
3587        assert!(msg.contains("no inner node produces it"), "got: {msg}");
3588        // The breadcrumb must name the referencing node so a failure several
3589        // levels down is findable.
3590        assert!(msg.contains("nested"), "got: {msg}");
3591    }
3592
3593    /// The `seen` set must reject a module that includes itself. Without the
3594    /// `seen.insert` guard this recurses to the depth limit and reports a
3595    /// misleading depth error instead of naming the cycle.
3596    #[test]
3597    fn check_module_file_rejects_self_referencing_module() {
3598        let tmp = TempDir::new().unwrap();
3599        let path = write_file(
3600            tmp.path(),
3601            "self.yml",
3602            r#"
3603module:
3604  name: selfref
3605  inputs: []
3606  outputs: []
3607
3608nodes:
3609  - id: me
3610    module: self.yml
3611"#,
3612        );
3613
3614        let msg = format!("{:#}", check_module_file(&path).unwrap_err());
3615        assert!(msg.contains("circular module reference"), "got: {msg}");
3616    }
3617
3618    /// `seen.remove` on the success path must keep a diamond (`a -> b -> d`
3619    /// and `a -> c -> d`) accepted. Drop that line and every shared nested
3620    /// module starts failing as a false "circular reference".
3621    #[test]
3622    fn check_module_file_accepts_diamond_module_graph() {
3623        let tmp = TempDir::new().unwrap();
3624        let base = tmp.path();
3625
3626        write_file(
3627            base,
3628            "d.yml",
3629            r#"
3630module:
3631  name: d
3632  inputs: []
3633  outputs: [d_out]
3634
3635nodes:
3636  - id: leaf
3637    path: leaf.py
3638    outputs:
3639      - d_out
3640"#,
3641        );
3642        for name in ["b.yml", "c.yml"] {
3643            write_file(
3644                base,
3645                name,
3646                r#"
3647module:
3648  name: mid
3649  inputs: []
3650  outputs: [d_out]
3651
3652nodes:
3653  - id: node_d
3654    module: d.yml
3655"#,
3656            );
3657        }
3658        let path = write_file(
3659            base,
3660            "a.yml",
3661            r#"
3662module:
3663  name: a
3664  inputs: []
3665  outputs: []
3666
3667nodes:
3668  - id: node_b
3669    module: b.yml
3670  - id: node_c
3671    module: c.yml
3672"#,
3673        );
3674
3675        check_module_file(&path).unwrap();
3676    }
3677
3678    #[test]
3679    fn check_module_file_rejects_depth_limit() {
3680        let tmp = TempDir::new().unwrap();
3681        let base = tmp.path();
3682
3683        for i in 0..=MAX_MODULE_DEPTH {
3684            let next = if i < MAX_MODULE_DEPTH {
3685                format!("  - id: inner\n    module: level{}_module.yml", i + 1)
3686            } else {
3687                "  - id: worker\n    path: worker.py\n    outputs:\n      - out".to_string()
3688            };
3689
3690            write_file(
3691                base,
3692                &format!("level{i}_module.yml"),
3693                &format!(
3694                    r#"
3695module:
3696  name: level{i}
3697  inputs: []
3698  outputs: [out]
3699
3700nodes:
3701{next}
3702"#
3703                ),
3704            );
3705        }
3706
3707        let result = check_module_file(&base.join("level0_module.yml"));
3708        assert!(result.is_err());
3709        // `{:#}` renders the whole context chain: the nested-module breadcrumb
3710        // wraps the underlying depth-limit error.
3711        assert!(format!("{:#}", result.unwrap_err()).contains("nesting exceeds depth limit"));
3712    }
3713
3714    /// Regression test for #2851: `check_module_file` must accept a nested
3715    /// module reference that points to a sibling directory inside the same
3716    /// project (e.g. `../shared/base.yml`). The real expansion path
3717    /// (`expand_module_node`) confines nested modules to the project root, not
3718    /// to the referencing module's own directory, so such a reference is valid
3719    /// and runnable -- the standalone linter must not spuriously reject it.
3720    #[test]
3721    fn check_module_file_accepts_cross_directory_nested_ref() {
3722        let tmp = TempDir::new().unwrap();
3723
3724        // project/modules/shared/base.yml -- the nested module.
3725        write_file(
3726            tmp.path(),
3727            "modules/shared/base.yml",
3728            r#"
3729module:
3730  name: base
3731  inputs: []
3732  outputs: [y]
3733
3734nodes:
3735  - id: inner
3736    path: inner.py
3737    outputs:
3738      - y
3739"#,
3740        );
3741
3742        // project/modules/a/mod.yml -- references the sibling module via `..`.
3743        let path = write_file(
3744            tmp.path(),
3745            "modules/a/mod.yml",
3746            r#"
3747module:
3748  name: outer
3749  inputs: []
3750  outputs: [y]
3751
3752nodes:
3753  - id: nested
3754    module: ../shared/base.yml
3755"#,
3756        );
3757
3758        // Must pass: the nested reference leaves `modules/a/` but stays inside
3759        // the project, exactly what real expansion accepts.
3760        check_module_file(&path).unwrap();
3761    }
3762
3763    // ---- Security tests ----
3764
3765    #[test]
3766    fn reject_absolute_module_path() {
3767        let desc = parse_descriptor(
3768            r#"
3769nodes:
3770  - id: evil
3771    module: /etc/passwd
3772"#,
3773        );
3774        let tmp = TempDir::new().unwrap();
3775        let result = expand_modules(&desc, tmp.path());
3776        assert!(result.is_err());
3777        let msg = result.unwrap_err().to_string();
3778        assert!(msg.contains("must be relative"), "got: {msg}");
3779    }
3780
3781    #[test]
3782    fn reject_path_traversal_module() {
3783        let tmp = TempDir::new().unwrap();
3784        let base = tmp.path();
3785
3786        // Create a module outside the base dir
3787        let parent = base.parent().unwrap();
3788        write_file(parent, "escape_module.yml", "module:\n  name: x\nnodes: []");
3789
3790        let desc = parse_descriptor(
3791            r#"
3792nodes:
3793  - id: evil
3794    module: ../escape_module.yml
3795"#,
3796        );
3797        let result = expand_modules(&desc, base);
3798        assert!(result.is_err());
3799        let msg = result.unwrap_err().to_string();
3800        assert!(msg.contains("escapes"), "got: {msg}");
3801    }
3802
3803    // This test guards the fix for #3341: an in-tree module directory that is
3804    // a symlink pointing outside the project root.
3805    #[test]
3806    fn expand_modules_accepts_symlinked_module_dir() {
3807        let tmp = TempDir::new().unwrap();
3808        let shared_dir = tmp.path().join("shared_modules");
3809        std::fs::create_dir_all(&shared_dir).unwrap();
3810        write_file(
3811            &shared_dir,
3812            "shared.yml",
3813            r#"
3814module:
3815  name: shared
3816  inputs: [in_val]
3817  outputs: [out_val]
3818
3819nodes:
3820  - id: worker
3821    path: worker.py
3822    inputs:
3823      data: _mod/in_val
3824    outputs:
3825      - out_val
3826"#,
3827        );
3828
3829        let project_dir = tmp.path().join("project");
3830        std::fs::create_dir_all(&project_dir).unwrap();
3831        let symlink_path = project_dir.join("modules");
3832        #[cfg(unix)]
3833        std::os::unix::fs::symlink(&shared_dir, &symlink_path).unwrap();
3834        #[cfg(windows)]
3835        if let Err(e) = std::os::windows::fs::symlink_dir(&shared_dir, &symlink_path) {
3836            eprintln!("skipping symlink test: {e}");
3837            return;
3838        }
3839
3840        let desc = parse_descriptor(
3841            r#"
3842nodes:
3843  - id: m
3844    module: modules/shared.yml
3845    inputs:
3846      in_val: source/data
3847"#,
3848        );
3849
3850        let expanded = expand_modules(&desc, &project_dir).unwrap();
3851        assert_eq!(expanded.nodes.len(), 1);
3852        assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
3853        assert_eq!(
3854            Path::new(expanded.nodes[0].path.as_ref().unwrap()),
3855            Path::new("modules/worker.py")
3856        );
3857    }
3858
3859    #[test]
3860    fn expand_modules_accepts_in_tree_symlink_with_dotdot() {
3861        let tmp = TempDir::new().unwrap();
3862        let project_dir = tmp.path().join("project");
3863        let real_modules = project_dir.join("real_modules");
3864        std::fs::create_dir_all(&real_modules).unwrap();
3865
3866        write_file(
3867            &project_dir,
3868            "top.yml",
3869            r#"
3870module:
3871  name: top
3872  inputs: [in_val]
3873  outputs: [out_val]
3874
3875nodes:
3876  - id: worker
3877    path: worker.py
3878    inputs:
3879      data: _mod/in_val
3880    outputs:
3881      - out_val
3882"#,
3883        );
3884
3885        let symlink_path = project_dir.join("modules");
3886        #[cfg(unix)]
3887        std::os::unix::fs::symlink(&real_modules, &symlink_path).unwrap();
3888        #[cfg(windows)]
3889        if let Err(e) = std::os::windows::fs::symlink_dir(&real_modules, &symlink_path) {
3890            eprintln!("skipping symlink test: {e}");
3891            return;
3892        }
3893
3894        // `modules/../top.yml` traverses `..` over the in-tree symlink `modules`.
3895        // Lexically and physically, it resolves to `project/top.yml`, within
3896        // the project directory and must be accepted.
3897        let desc = parse_descriptor(
3898            r#"
3899nodes:
3900  - id: m
3901    module: modules/../top.yml
3902    inputs:
3903      in_val: source/data
3904"#,
3905        );
3906
3907        let expanded = expand_modules(&desc, &project_dir).unwrap();
3908        assert_eq!(expanded.nodes.len(), 1);
3909        assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
3910        assert_eq!(
3911            Path::new(expanded.nodes[0].path.as_ref().unwrap()),
3912            Path::new("worker.py")
3913        );
3914    }
3915
3916    #[test]
3917    fn reject_symlinked_module_dir_divergent_lexical_and_physical() {
3918        let tmp = TempDir::new().unwrap();
3919        let external_base = tmp.path().join("external");
3920        let external_modules = external_base.join("modules");
3921        let external_shared = external_base.join("shared");
3922        std::fs::create_dir_all(&external_modules).unwrap();
3923        std::fs::create_dir_all(&external_shared).unwrap();
3924        write_file(
3925            &external_shared,
3926            "x.yml",
3927            "module:\n  name: x\n  inputs: []\n  outputs: []\nnodes: []",
3928        );
3929
3930        let project_dir = tmp.path().join("project");
3931        std::fs::create_dir_all(&project_dir).unwrap();
3932
3933        let symlink_path = project_dir.join("modules");
3934        #[cfg(unix)]
3935        std::os::unix::fs::symlink(&external_modules, &symlink_path).unwrap();
3936        #[cfg(windows)]
3937        if let Err(e) = std::os::windows::fs::symlink_dir(&external_modules, &symlink_path) {
3938            eprintln!("skipping symlink test: {e}");
3939            return;
3940        }
3941
3942        // Also create project/shared/x.yml so lexical resolution finds a file inside project
3943        let project_shared = project_dir.join("shared");
3944        std::fs::create_dir_all(&project_shared).unwrap();
3945        write_file(
3946            &project_shared,
3947            "x.yml",
3948            "module:\n  name: project_x\n  inputs: []\n  outputs: []\nnodes: []",
3949        );
3950
3951        // `modules/../shared/x.yml`:
3952        // Lexically resolves to `project/shared/x.yml`.
3953        // Physically resolves to `external/shared/x.yml`.
3954        // Lexical and physical resolutions diverge, so this must be rejected.
3955        let desc = parse_descriptor(
3956            r#"
3957nodes:
3958  - id: m
3959    module: modules/../shared/x.yml
3960"#,
3961        );
3962
3963        let result = expand_modules(&desc, &project_dir);
3964        assert!(result.is_err(), "expected error but succeeded");
3965        let msg = result.unwrap_err().to_string();
3966        assert!(msg.contains("escapes"), "got: {msg}");
3967    }
3968
3969    #[test]
3970    fn reject_symlinked_module_dir_dotdot_escape() {
3971        let tmp = TempDir::new().unwrap();
3972        let shared_base = tmp.path().join("shared_base");
3973        let shared_dir = shared_base.join("shared_modules");
3974        std::fs::create_dir_all(&shared_dir).unwrap();
3975        write_file(
3976            &shared_dir,
3977            "shared.yml",
3978            "module:\n  name: shared\n  inputs: []\n  outputs: []\nnodes: []",
3979        );
3980
3981        // Place escape.yml outside the symlink target (in shared_base)
3982        write_file(
3983            &shared_base,
3984            "escape.yml",
3985            "module:\n  name: escape\n  inputs: []\n  outputs: []\nnodes: []",
3986        );
3987
3988        let project_dir = tmp.path().join("project");
3989        std::fs::create_dir_all(&project_dir).unwrap();
3990
3991        let symlink_path = project_dir.join("modules");
3992        #[cfg(unix)]
3993        std::os::unix::fs::symlink(&shared_dir, &symlink_path).unwrap();
3994        #[cfg(windows)]
3995        if let Err(e) = std::os::windows::fs::symlink_dir(&shared_dir, &symlink_path) {
3996            eprintln!("skipping symlink test: {e}");
3997            return;
3998        }
3999
4000        // `modules/../escape.yml` uses a single `..`.
4001        // Lexically, `project/modules/..` collapses to `project/`, which
4002        // would pass a purely lexical containment check.
4003        // Physically, `modules` dereferences to `shared_modules`, and `..`
4004        // escapes into `shared_base/escape.yml`.
4005        // This must be rejected as an escape.
4006        let desc = parse_descriptor(
4007            r#"
4008nodes:
4009  - id: m
4010    module: modules/../escape.yml
4011"#,
4012        );
4013
4014        let result = expand_modules(&desc, &project_dir);
4015        assert!(result.is_err(), "expected error but succeeded");
4016        let msg = result.unwrap_err().to_string();
4017        assert!(msg.contains("escapes"), "got: {msg}");
4018    }
4019
4020    #[test]
4021    fn expand_modules_accepts_dotdot_in_base_dir() {
4022        let tmp = TempDir::new().unwrap();
4023        let sub = tmp.path().join("sub");
4024        std::fs::create_dir_all(&sub).unwrap();
4025        let project_dir = sub.join("..").join("project");
4026        std::fs::create_dir_all(&project_dir).unwrap();
4027        write_file(
4028            &project_dir,
4029            "mod.yml",
4030            r#"
4031module:
4032  name: inner
4033  inputs: [in_val]
4034  outputs: [out_val]
4035
4036nodes:
4037  - id: worker
4038    path: worker.py
4039    inputs:
4040      data: _mod/in_val
4041    outputs:
4042      - out_val
4043"#,
4044        );
4045
4046        let desc = parse_descriptor(
4047            r#"
4048nodes:
4049  - id: m
4050    module: mod.yml
4051    inputs:
4052      in_val: source/data
4053"#,
4054        );
4055
4056        let expanded = expand_modules(&desc, &project_dir).unwrap();
4057        assert_eq!(expanded.nodes.len(), 1);
4058        assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
4059        assert_eq!(
4060            Path::new(expanded.nodes[0].path.as_ref().unwrap()),
4061            Path::new("worker.py")
4062        );
4063    }
4064
4065    /// A `git:` inner node's `path` names a file inside its clone, which is
4066    /// the node's working dir at build and spawn. Re-rooting it to the module
4067    /// directory made the daemon look for `<clone>/<module_dir>/<path>`.
4068    #[test]
4069    fn expand_keeps_git_inner_node_path_relative_to_the_clone() {
4070        let tmp = TempDir::new().unwrap();
4071        let base = tmp.path();
4072        std::fs::create_dir_all(base.join("modules")).unwrap();
4073
4074        write_file(
4075            &base.join("modules"),
4076            "git_module.yml",
4077            r#"
4078module:
4079  name: git_module
4080  outputs: [from_git, local]
4081
4082nodes:
4083  - id: fromgit
4084    git: https://github.com/example/x.git
4085    path: ../bin/x
4086    outputs:
4087      - from_git
4088  - id: sibling
4089    path: worker.py
4090    outputs:
4091      - local
4092"#,
4093        );
4094
4095        let desc = parse_descriptor(
4096            r#"
4097nodes:
4098  - id: m
4099    module: modules/git_module.yml
4100"#,
4101        );
4102
4103        let expanded = expand_modules(&desc, base).unwrap();
4104        let path_of = |id: &str| {
4105            expanded
4106                .nodes
4107                .iter()
4108                .find(|n| n.id.to_string() == id)
4109                .and_then(|n| n.path.clone())
4110                .unwrap()
4111        };
4112        assert_eq!(path_of("m.fromgit"), "../bin/x");
4113        // A plain `path:` sibling is still re-rooted to the module directory.
4114        assert_eq!(
4115            Path::new(&path_of("m.sibling")),
4116            Path::new("modules").join("worker.py")
4117        );
4118    }
4119
4120    #[test]
4121    fn reject_inner_node_path_traversal_via_dotdot() {
4122        let tmp = TempDir::new().unwrap();
4123        let base = tmp.path();
4124
4125        // Module file that declares an inner node with a `..`-escaping path.
4126        // The binary doesn't need to exist — the check is purely lexical.
4127        write_file(
4128            base,
4129            "escape_node_module.yml",
4130            r#"
4131module:
4132  name: escape_node
4133  inputs: []
4134  outputs: []
4135
4136nodes:
4137  - id: evil
4138    path: ../../etc/evil-binary
4139"#,
4140        );
4141
4142        let desc = parse_descriptor(
4143            r#"
4144nodes:
4145  - id: m
4146    module: escape_node_module.yml
4147"#,
4148        );
4149
4150        let result = expand_modules(&desc, base);
4151        assert!(result.is_err(), "expected error but got success");
4152        let msg = result.unwrap_err().to_string();
4153        assert!(
4154            msg.contains("resolves outside the project directory"),
4155            "got: {msg}"
4156        );
4157    }
4158
4159    /// A `dora/logs/<level>/<node>` input naming a sibling inside a module must
4160    /// follow the sibling's prefixed id, or the daemon's exact node-id filter
4161    /// matches nothing and the sink silently receives no logs.
4162    #[test]
4163    fn expand_prefixes_sibling_log_filter() {
4164        let tmp = TempDir::new().unwrap();
4165        let base = tmp.path();
4166
4167        write_file(
4168            base,
4169            "logs_module.yml",
4170            r#"
4171module:
4172  name: logs_module
4173  outputs: []
4174
4175nodes:
4176  - id: worker
4177    path: worker.py
4178  - id: sink
4179    path: sink.py
4180    inputs:
4181      sibling: dora/logs/info/worker
4182      external: dora/logs/warn/outside
4183      all: dora/logs/info
4184"#,
4185        );
4186
4187        let desc = parse_descriptor(
4188            r#"
4189nodes:
4190  - id: m
4191    module: logs_module.yml
4192  - id: outside
4193    path: outside.py
4194"#,
4195        );
4196
4197        let expanded = expand_modules(&desc, base).unwrap();
4198        let sink = expanded
4199            .nodes
4200            .iter()
4201            .find(|n| n.id.to_string() == "m.sink")
4202            .unwrap();
4203        let filter_of = |input: &str| match &sink.inputs[&DataId::from(input.to_string())].mapping {
4204            InputMapping::Logs(filter) => filter.clone(),
4205            other => panic!("expected a logs mapping, got {other:?}"),
4206        };
4207
4208        let sibling = filter_of("sibling");
4209        assert_eq!(sibling.node_filter.unwrap().to_string(), "m.worker");
4210        assert!(sibling.min_level.is_some(), "level must be preserved");
4211        // A node outside the module and an unfiltered subscription are left as is.
4212        assert_eq!(
4213            filter_of("external").node_filter.unwrap().to_string(),
4214            "outside"
4215        );
4216        assert!(filter_of("all").node_filter.is_none());
4217    }
4218
4219    #[test]
4220    fn reject_inner_operator_source_path_traversal_via_dotdot() {
4221        let tmp = TempDir::new().unwrap();
4222        let base = tmp.path();
4223
4224        write_file(
4225            base,
4226            "escape_operator_module.yml",
4227            r#"
4228module:
4229  name: escape_operator
4230  inputs: []
4231  outputs: []
4232
4233nodes:
4234  - id: runtime
4235    operators:
4236      - id: evil
4237        shared-library: ../../etc/evil-operator
4238"#,
4239        );
4240
4241        let desc = parse_descriptor(
4242            r#"
4243nodes:
4244  - id: m
4245    module: escape_operator_module.yml
4246"#,
4247        );
4248
4249        let result = expand_modules(&desc, base);
4250        assert!(result.is_err(), "expected error but got success");
4251        let msg = result.unwrap_err().to_string();
4252        assert!(
4253            msg.contains("resolves outside the project directory"),
4254            "got: {msg}"
4255        );
4256    }
4257
4258    #[test]
4259    fn reject_invalid_param_key() {
4260        let tmp = TempDir::new().unwrap();
4261        let base = tmp.path();
4262
4263        write_file(
4264            base,
4265            "param_mod.yml",
4266            r#"
4267module:
4268  name: p
4269  inputs: [x]
4270  outputs: [y]
4271
4272nodes:
4273  - id: n
4274    path: n.py
4275    inputs:
4276      x: _mod/x
4277    outputs: [y]
4278"#,
4279        );
4280
4281        let desc = parse_descriptor(
4282            r#"
4283nodes:
4284  - id: src
4285    path: src.py
4286    outputs: [v]
4287  - id: m
4288    module: param_mod.yml
4289    inputs:
4290      x: src/v
4291    params:
4292      "bad}key": value
4293"#,
4294        );
4295        let result = expand_modules(&desc, base);
4296        assert!(result.is_err());
4297        let msg = result.unwrap_err().to_string();
4298        assert!(msg.contains("invalid param key"), "got: {msg}");
4299    }
4300
4301    #[test]
4302    fn reject_case_colliding_param_keys() {
4303        let tmp = TempDir::new().unwrap();
4304        let base = tmp.path();
4305
4306        write_file(
4307            base,
4308            "param_mod.yml",
4309            r#"
4310module:
4311  name: p
4312  inputs: [x]
4313  outputs: [y]
4314
4315nodes:
4316  - id: n
4317    path: n.py
4318    inputs:
4319      x: _mod/x
4320    outputs: [y]
4321"#,
4322        );
4323
4324        // `mode` and `Mode` are both valid keys but both map to `PARAM_MODE`,
4325        // so one would silently overwrite the other in the node env.
4326        let desc = parse_descriptor(
4327            r#"
4328nodes:
4329  - id: src
4330    path: src.py
4331    outputs: [v]
4332  - id: m
4333    module: param_mod.yml
4334    inputs:
4335      x: src/v
4336    params:
4337      mode: safe
4338      Mode: turbo
4339"#,
4340        );
4341        let result = expand_modules(&desc, base);
4342        assert!(result.is_err());
4343        let msg = result.unwrap_err().to_string();
4344        assert!(msg.contains("PARAM_MODE"), "got: {msg}");
4345    }
4346
4347    #[test]
4348    fn reject_duplicate_node_ids() {
4349        let tmp = TempDir::new().unwrap();
4350        let base = tmp.path();
4351
4352        write_file(
4353            base,
4354            "dup_module.yml",
4355            r#"
4356module:
4357  name: dup
4358  inputs: []
4359  outputs: [y]
4360
4361nodes:
4362  - id: inner
4363    path: inner.py
4364    outputs: [y]
4365"#,
4366        );
4367
4368        // Top-level node named "m.inner" collides with module expansion
4369        let desc = parse_descriptor(
4370            r#"
4371nodes:
4372  - id: m.inner
4373    path: other.py
4374    outputs: [z]
4375  - id: m
4376    module: dup_module.yml
4377"#,
4378        );
4379        let result = expand_modules(&desc, base);
4380        assert!(result.is_err());
4381        let msg = result.unwrap_err().to_string();
4382        assert!(msg.contains("duplicate node ID"), "got: {msg}");
4383    }
4384
4385    #[test]
4386    fn expand_nested_module_build_propagated() {
4387        let tmp = TempDir::new().unwrap();
4388        let base = tmp.path();
4389
4390        write_file(
4391            base,
4392            "leaf_module.yml",
4393            r#"
4394module:
4395  name: leaf
4396  inputs: [x]
4397  outputs: [y]
4398
4399nodes:
4400  - id: worker
4401    path: worker.py
4402    inputs:
4403      x: _mod/x
4404    outputs:
4405      - y
4406"#,
4407        );
4408
4409        write_file(
4410            base,
4411            "outer_module.yml",
4412            r#"
4413module:
4414  name: outer
4415  inputs: [x]
4416  outputs: [y]
4417
4418build: pip install outer-deps
4419
4420nodes:
4421  - id: inner
4422    module: leaf_module.yml
4423    inputs:
4424      x: _mod/x
4425"#,
4426        );
4427
4428        let desc = parse_descriptor(
4429            r#"
4430nodes:
4431  - id: src
4432    path: src.py
4433    outputs: [val]
4434  - id: top
4435    module: outer_module.yml
4436    inputs:
4437      x: src/val
4438"#,
4439        );
4440
4441        let expanded = expand_modules(&desc, base).unwrap();
4442        let worker = expanded
4443            .nodes
4444            .iter()
4445            .find(|n| n.id.to_string() == "top.inner.worker")
4446            .unwrap();
4447        let build = worker.build.as_deref().unwrap();
4448        assert!(
4449            build.contains("pip install outer-deps"),
4450            "outer module build must propagate through nested modules; got: {build}"
4451        );
4452    }
4453
4454    #[test]
4455    fn param_override_precedence() {
4456        let tmp = TempDir::new().unwrap();
4457        let base = tmp.path();
4458
4459        write_file(
4460            base,
4461            "override_mod.yml",
4462            r#"
4463module:
4464  name: override
4465  inputs: [x]
4466  outputs: [y]
4467
4468nodes:
4469  - id: proc
4470    path: proc.py
4471    inputs:
4472      x: _mod/x
4473    outputs: [y]
4474    env:
4475      PARAM_SPEED: "default_value"
4476"#,
4477        );
4478
4479        let desc = parse_descriptor(
4480            r#"
4481nodes:
4482  - id: src
4483    path: src.py
4484    outputs: [v]
4485  - id: m
4486    module: override_mod.yml
4487    inputs:
4488      x: src/v
4489    params:
4490      speed: "caller_value"
4491"#,
4492        );
4493
4494        let expanded = expand_modules(&desc, base).unwrap();
4495        let proc = expanded
4496            .nodes
4497            .iter()
4498            .find(|n| n.id.to_string() == "m.proc")
4499            .unwrap();
4500        let env = proc.env.as_ref().unwrap();
4501        // Caller params should override inner default
4502        assert_eq!(
4503            env["PARAM_SPEED"],
4504            EnvValue::String("caller_value".to_string())
4505        );
4506    }
4507
4508    /// Regression test for #2441: operator (runtime) inner nodes wire their
4509    /// inputs through `config.inputs`, not the node-level `inputs` map, so
4510    /// `_mod/` and sibling references inside an operator input must be
4511    /// rewritten the same way as node-level inputs.
4512    #[test]
4513    fn expand_rewrites_operator_inputs() {
4514        let tmp = TempDir::new().unwrap();
4515        let base = tmp.path();
4516
4517        write_file(
4518            base,
4519            "runtime_module.yml",
4520            r#"
4521module:
4522  name: rt
4523  inputs: [data]
4524
4525nodes:
4526  - id: stage_a
4527    path: a.py
4528    outputs: [aux]
4529
4530  - id: runtime
4531    operators:
4532      - id: proc
4533        shared-library: proc.so
4534        inputs:
4535          x: _mod/data
4536          y: stage_a/aux
4537        outputs:
4538          - result
4539"#,
4540        );
4541
4542        let desc = parse_descriptor(
4543            r#"
4544nodes:
4545  - id: src
4546    path: src.py
4547    outputs: [val]
4548  - id: m
4549    module: runtime_module.yml
4550    inputs:
4551      data: src/val
4552"#,
4553        );
4554
4555        let expanded = expand_modules(&desc, base).unwrap();
4556
4557        let runtime = expanded
4558            .nodes
4559            .iter()
4560            .find(|n| n.id.to_string() == "m.runtime")
4561            .unwrap();
4562        let proc = runtime
4563            .operators
4564            .as_ref()
4565            .unwrap()
4566            .operators
4567            .iter()
4568            .find(|op| op.id.to_string() == "proc")
4569            .unwrap();
4570
4571        let x = &proc.config.inputs[&DataId::from("x".to_string())];
4572        match &x.mapping {
4573            InputMapping::User(m) => {
4574                assert_eq!(m.source.to_string(), "src");
4575                assert_eq!(m.output.to_string(), "val");
4576            }
4577            _ => panic!("expected user mapping"),
4578        }
4579
4580        let y = &proc.config.inputs[&DataId::from("y".to_string())];
4581        match &y.mapping {
4582            InputMapping::User(m) => {
4583                assert_eq!(m.source.to_string(), "m.stage_a");
4584                assert_eq!(m.output.to_string(), "aux");
4585            }
4586            _ => panic!("expected user mapping"),
4587        }
4588    }
4589
4590    /// Regression test for #2441: `check_module_file` must catch an invalid
4591    /// `_mod/` reference inside an operator input, not just node-level inputs.
4592    #[test]
4593    fn check_module_file_rejects_invalid_mod_ref_in_operator_input() {
4594        let tmp = TempDir::new().unwrap();
4595        let base = tmp.path();
4596
4597        let path = write_file(
4598            base,
4599            "bad_operator_module.yml",
4600            r#"
4601module:
4602  name: bad
4603  inputs: [data]
4604  outputs: [result]
4605
4606nodes:
4607  - id: runtime
4608    operators:
4609      - id: proc
4610        shared-library: proc.so
4611        inputs:
4612          x: _mod/nonexistent
4613        outputs:
4614          - result
4615"#,
4616        );
4617
4618        let err = check_module_file(&path).unwrap_err();
4619        assert!(err.to_string().contains("_mod/nonexistent"));
4620    }
4621
4622    #[test]
4623    fn expand_resolves_inner_operator_and_node_sources_relative_to_module_file() {
4624        let tmp = TempDir::new().unwrap();
4625        let base = tmp.path();
4626
4627        write_file(
4628            base,
4629            "modules/nested/source_paths.yml",
4630            r#"
4631module:
4632  name: source_paths
4633  inputs: [data]
4634  outputs: [from_runtime, from_operator, from_path]
4635
4636nodes:
4637  - id: runtime
4638    operators:
4639      - id: proc
4640        shared-library: libproc.so
4641        inputs:
4642          x: _mod/data
4643        outputs:
4644          - from_runtime
4645
4646  - id: single
4647    operator:
4648      python: single.py
4649      inputs:
4650        x: _mod/data
4651      outputs:
4652        - from_operator
4653
4654  - id: runner
4655    path: runner.py
4656    inputs:
4657      x: _mod/data
4658    outputs:
4659      - from_path
4660"#,
4661        );
4662
4663        let desc = parse_descriptor(
4664            r#"
4665nodes:
4666  - id: src
4667    path: src.py
4668    outputs: [val]
4669  - id: m
4670    module: modules/nested/source_paths.yml
4671    inputs:
4672      data: src/val
4673"#,
4674        );
4675
4676        let expanded = expand_modules(&desc, base).unwrap();
4677        let runtime = expanded
4678            .nodes
4679            .iter()
4680            .find(|n| n.id.to_string() == "m.runtime")
4681            .unwrap();
4682        let proc_source = &runtime
4683            .operators
4684            .as_ref()
4685            .unwrap()
4686            .operators
4687            .first()
4688            .unwrap()
4689            .config
4690            .source;
4691        // `normalize_path` builds the rewritten path with `MAIN_SEPARATOR`,
4692        // so on Windows these come back as `modules\\nested\\...`. Compare on a
4693        // normalized form -- CI runs this suite on windows-latest nightly.
4694        assert!(
4695            matches!(proc_source, dora_message::descriptor::OperatorSource::SharedLibrary(path) if path.replace('\\', "/") == "modules/nested/libproc.so"),
4696            "unexpected runtime operator source: {proc_source:?}"
4697        );
4698
4699        let single = expanded
4700            .nodes
4701            .iter()
4702            .find(|n| n.id.to_string() == "m.single")
4703            .unwrap();
4704        let single_source = &single.operator.as_ref().unwrap().config.source;
4705        assert!(
4706            matches!(single_source, dora_message::descriptor::OperatorSource::Python(source) if source.source.replace('\\', "/") == "modules/nested/single.py"),
4707            "unexpected single operator source: {single_source:?}"
4708        );
4709
4710        let runner = expanded
4711            .nodes
4712            .iter()
4713            .find(|n| n.id.to_string() == "m.runner")
4714            .unwrap();
4715        assert_eq!(
4716            runner
4717                .path
4718                .as_deref()
4719                .map(|p| p.replace('\\', "/"))
4720                .as_deref(),
4721            Some("modules/nested/runner.py")
4722        );
4723    }
4724
4725    /// `path: dynamic` is a sentinel, not a file: the daemon matches it
4726    /// verbatim to decide a node is externally spawned. Module expansion must
4727    /// leave it alone, or a module in a subdirectory turns it into
4728    /// `modules/nested/dynamic` and the node is spawned as a binary instead.
4729    #[test]
4730    fn expand_preserves_dynamic_source_sentinel() {
4731        let tmp = TempDir::new().unwrap();
4732        let base = tmp.path();
4733
4734        write_file(
4735            base,
4736            "modules/nested/dynamic_source.yml",
4737            r#"
4738module:
4739  name: dynamic_source
4740  inputs: [data]
4741  outputs: [result]
4742
4743nodes:
4744  - id: dyn
4745    path: dynamic
4746    inputs:
4747      x: _mod/data
4748    outputs:
4749      - result
4750"#,
4751        );
4752
4753        let desc = parse_descriptor(
4754            r#"
4755nodes:
4756  - id: src
4757    path: src.py
4758    outputs: [val]
4759  - id: m
4760    module: modules/nested/dynamic_source.yml
4761    inputs:
4762      data: src/val
4763"#,
4764        );
4765
4766        let expanded = expand_modules(&desc, base).unwrap();
4767        let dyn_node = expanded
4768            .nodes
4769            .iter()
4770            .find(|n| n.id.to_string() == "m.dyn")
4771            .unwrap();
4772
4773        assert_eq!(dyn_node.path.as_deref(), Some("dynamic"));
4774    }
4775
4776    /// Same as the `dynamic` case: `path: shell` tells the daemon to run
4777    /// `args` through a shell, so expansion must not rewrite it into a path.
4778    #[test]
4779    fn expand_preserves_shell_source_sentinel() {
4780        let tmp = TempDir::new().unwrap();
4781        let base = tmp.path();
4782
4783        write_file(
4784            base,
4785            "modules/nested/shell_source.yml",
4786            r#"
4787module:
4788  name: shell_source
4789  inputs: [data]
4790  outputs: [result]
4791
4792nodes:
4793  - id: shell
4794    path: shell
4795    args: echo hi
4796    inputs:
4797      x: _mod/data
4798    outputs:
4799      - result
4800"#,
4801        );
4802
4803        let desc = parse_descriptor(
4804            r#"
4805nodes:
4806  - id: src
4807    path: src.py
4808    outputs: [val]
4809  - id: m
4810    module: modules/nested/shell_source.yml
4811    inputs:
4812      data: src/val
4813"#,
4814        );
4815
4816        let expanded = expand_modules(&desc, base).unwrap();
4817        let shell_node = expanded
4818            .nodes
4819            .iter()
4820            .find(|n| n.id.to_string() == "m.shell")
4821            .unwrap();
4822
4823        assert_eq!(shell_node.path.as_deref(), Some("shell"));
4824    }
4825
4826    /// Expand a dataflow whose single module node `m` re-exports `result`, and
4827    /// return the mapping the downstream `sink` node ends up with. Also asserts
4828    /// that the expanded dataflow passes wiring validation, which is what
4829    /// catches a producer reference that is syntactically plausible but wrong
4830    /// for the producer's node kind (e.g. a missing operator segment).
4831    fn expand_and_resolve_sink_input(base: &Path) -> UserInputMapping {
4832        let desc = parse_descriptor(
4833            r#"
4834nodes:
4835  - id: src
4836    path: src.py
4837    outputs: [val]
4838  - id: m
4839    module: mod.yml
4840    inputs:
4841      data: src/val
4842  - id: sink
4843    path: sink.py
4844    inputs:
4845      r: m/result
4846"#,
4847        );
4848
4849        let expanded = expand_modules(&desc, base).unwrap();
4850        crate::descriptor::validate::check_wiring(&expanded).unwrap();
4851
4852        let sink = expanded
4853            .nodes
4854            .iter()
4855            .find(|n| n.id.to_string() == "sink")
4856            .unwrap();
4857        match &sink.inputs[&DataId::from("r".to_string())].mapping {
4858            InputMapping::User(m) => m.clone(),
4859            _ => panic!("expected user mapping"),
4860        }
4861    }
4862
4863    /// Regression test for #2817: a module output produced by an operator of a
4864    /// multi-operator runtime node must resolve. Consumers of a runtime node
4865    /// address outputs as `<node>/<operator>/<output>`, so the rewritten
4866    /// reference has to keep the `proc/` segment.
4867    #[test]
4868    fn expand_resolves_operator_produced_module_output() {
4869        let tmp = TempDir::new().unwrap();
4870        let base = tmp.path();
4871
4872        write_file(
4873            base,
4874            "mod.yml",
4875            r#"
4876module:
4877  name: rt
4878  inputs: [data]
4879  outputs: [result]
4880
4881nodes:
4882  - id: runtime
4883    operators:
4884      - id: proc
4885        shared-library: proc.so
4886        inputs:
4887          x: _mod/data
4888        outputs:
4889          - result
4890"#,
4891        );
4892
4893        let mapping = expand_and_resolve_sink_input(base);
4894        assert_eq!(mapping.source.to_string(), "m.runtime");
4895        assert_eq!(mapping.output.to_string(), "proc/result");
4896    }
4897
4898    /// Regression test for #2817: same for a single `operator:` node. Here the
4899    /// output stays bare — `resolve_aliases_and_set_defaults` injects the `op/`
4900    /// prefix afterwards, so adding it during expansion would double it up.
4901    #[test]
4902    fn expand_resolves_single_operator_produced_module_output() {
4903        let tmp = TempDir::new().unwrap();
4904        let base = tmp.path();
4905
4906        write_file(
4907            base,
4908            "mod.yml",
4909            r#"
4910module:
4911  name: rt
4912  inputs: [data]
4913  outputs: [result]
4914
4915nodes:
4916  - id: runtime
4917    operator:
4918      shared-library: proc.so
4919      inputs:
4920        x: _mod/data
4921      outputs:
4922        - result
4923"#,
4924        );
4925
4926        let mapping = expand_and_resolve_sink_input(base);
4927        assert_eq!(mapping.source.to_string(), "m.runtime");
4928        assert_eq!(mapping.output.to_string(), "result");
4929    }
4930
4931    #[test]
4932    fn expand_rejects_ambiguous_module_output() {
4933        let tmp = TempDir::new().unwrap();
4934        let base = tmp.path();
4935
4936        write_file(
4937            base,
4938            "mod.yml",
4939            r#"
4940module:
4941  name: ambiguous
4942  inputs: []
4943  outputs: [out]
4944
4945nodes:
4946  - id: first
4947    path: first.py
4948    outputs:
4949      - out
4950  - id: second
4951    path: second.py
4952    outputs:
4953      - out
4954"#,
4955        );
4956
4957        let desc = parse_descriptor(
4958            r#"
4959nodes:
4960  - id: m
4961    module: mod.yml
4962  - id: sink
4963    path: sink.py
4964    inputs:
4965      value: m/out
4966"#,
4967        );
4968
4969        let result = expand_modules(&desc, base);
4970        assert!(result.is_err());
4971        let msg = result.unwrap_err().to_string();
4972        assert!(msg.contains("out"), "got: {msg}");
4973        assert!(msg.contains("multiple"), "got: {msg}");
4974        assert!(msg.contains("m.first"), "got: {msg}");
4975        assert!(msg.contains("m.second"), "got: {msg}");
4976    }
4977
4978    #[test]
4979    fn expand_rejects_nested_module_private_output_export() {
4980        let tmp = TempDir::new().unwrap();
4981        let base = tmp.path();
4982
4983        write_file(
4984            base,
4985            "leaf.yml",
4986            r#"
4987module:
4988  name: leaf
4989  inputs: []
4990  outputs: [public]
4991
4992nodes:
4993  - id: worker
4994    path: worker.py
4995    outputs:
4996      - public
4997      - private
4998"#,
4999        );
5000
5001        write_file(
5002            base,
5003            "outer.yml",
5004            r#"
5005module:
5006  name: outer
5007  inputs: []
5008  outputs: [private]
5009
5010nodes:
5011  - id: nested
5012    module: leaf.yml
5013"#,
5014        );
5015
5016        let desc = parse_descriptor(
5017            r#"
5018nodes:
5019  - id: m
5020    module: outer.yml
5021  - id: sink
5022    path: sink.py
5023    inputs:
5024      value: m/private
5025"#,
5026        );
5027
5028        let result = expand_modules(&desc, base);
5029        assert!(result.is_err());
5030        let msg = result.unwrap_err().to_string();
5031        assert!(msg.contains("private"), "got: {msg}");
5032        assert!(msg.contains("no inner node produces it"), "got: {msg}");
5033    }
5034
5035    /// Regression test for #2817: `check_module_file` must accept a declared
5036    /// output produced by an operator inner node, and still reject one nothing
5037    /// produces.
5038    #[test]
5039    fn check_module_file_accepts_operator_produced_outputs() {
5040        let tmp = TempDir::new().unwrap();
5041        let base = tmp.path();
5042
5043        let path = write_file(
5044            base,
5045            "mixed_module.yml",
5046            r#"
5047module:
5048  name: mixed
5049  inputs: [data]
5050  outputs: [from_operator]
5051
5052nodes:
5053  - id: runtime
5054    operators:
5055      - id: proc
5056        shared-library: proc.so
5057        inputs:
5058          x: _mod/data
5059        outputs:
5060          - from_operator
5061"#,
5062        );
5063        check_module_file(&path).unwrap();
5064
5065        let missing = write_file(
5066            base,
5067            "missing_module.yml",
5068            r#"
5069module:
5070  name: missing
5071  outputs: [nobody_produces_this]
5072
5073nodes:
5074  - id: runtime
5075    operators:
5076      - id: proc
5077        shared-library: proc.so
5078        outputs:
5079          - something_else
5080"#,
5081        );
5082        let err = check_module_file(&missing).unwrap_err().to_string();
5083        assert!(err.contains("no inner node produces it"), "{err}");
5084    }
5085}