Skip to main content

brink_runtime/
linker.rs

1//! Links [`StoryData`] into an executable [`Program`].
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use brink_format::{DefinitionId, NameId, StoryData};
7
8use crate::collections::{Map as HashMap, map_with_capacity};
9use crate::error::RuntimeError;
10use crate::program::{
11    ExternalFnEntry, GlobalSlot, LinkedContainer, ListDefEntry, ListItemEntry, PathTarget, Program,
12    StructShapeEntry,
13};
14
15/// Look up a `NameId` in `StoryData::name_table`, failing cleanly on an
16/// out-of-range index instead of panicking. `NameId`s embedded in
17/// malformed/adversarial bytecode are not guaranteed to be in range — this
18/// is the linker's own validation, the sanctioned way for such a program to
19/// stop (never an unchecked index panic).
20fn resolve_name(data: &StoryData, name_id: NameId) -> Result<String, RuntimeError> {
21    data.name_table
22        .get(name_id.0 as usize)
23        .cloned()
24        .ok_or(RuntimeError::InvalidNameId(name_id.0))
25}
26
27/// Link a [`StoryData`] into an executable [`Program`].
28///
29/// Builds lookup tables mapping [`DefinitionId`]s to flat array indices.
30/// The root container is `containers[0]` by convention — the brink compiler
31/// emits the root first.
32#[expect(clippy::cast_possible_truncation, clippy::too_many_lines)]
33pub fn link(
34    data: &StoryData,
35) -> Result<(Program, Vec<Vec<brink_format::LineEntry>>), RuntimeError> {
36    let mut container_map = map_with_capacity(data.containers.len());
37
38    for (i, cdef) in data.containers.iter().enumerate() {
39        let idx = i as u32;
40        container_map.insert(cdef.id, idx);
41    }
42
43    // Build scope line tables and a map from scope_id → table index.
44    let mut scope_table_map: HashMap<DefinitionId, u32> = map_with_capacity(data.line_tables.len());
45    let mut line_tables: Vec<Vec<brink_format::LineEntry>> =
46        Vec::with_capacity(data.line_tables.len());
47    let mut scope_ids: Vec<DefinitionId> = Vec::with_capacity(data.line_tables.len());
48    for lt in &data.line_tables {
49        let idx = line_tables.len() as u32;
50        scope_table_map.insert(lt.scope_id, idx);
51        scope_ids.push(lt.scope_id);
52        line_tables.push(lt.lines.clone());
53    }
54
55    // Build containers with scope_table_idx.
56    let mut containers = Vec::with_capacity(data.containers.len());
57    for cdef in &data.containers {
58        let scope_table_idx = scope_table_map.get(&cdef.scope_id).copied().unwrap_or(0);
59        containers.push(LinkedContainer {
60            id: cdef.id,
61            bytecode: cdef.bytecode.clone(),
62            counting_flags: cdef.counting_flags,
63            path_hash: cdef.path_hash,
64            param_count: cdef.param_count,
65            params: cdef.params.clone(),
66            scope_table_idx,
67        });
68    }
69
70    // Build globals.
71    let mut globals = Vec::with_capacity(data.variables.len());
72    let mut global_map = map_with_capacity(data.variables.len());
73    for (i, gvar) in data.variables.iter().enumerate() {
74        let idx = i as u32;
75        global_map.insert(gvar.id, idx);
76        globals.push(GlobalSlot {
77            id: gvar.id,
78            name: gvar.name,
79            default: gvar.default_value.clone(),
80            local: gvar.local,
81        });
82    }
83
84    // Build unified address map from containers and address defs.
85    // Containers get offset 0 (primary addresses).
86    let mut address_map = map_with_capacity(data.containers.len() + data.addresses.len());
87    for (i, cdef) in data.containers.iter().enumerate() {
88        address_map.insert(cdef.id, (i as u32, 0usize));
89    }
90    // Address defs add intra-container targets (and primary addresses from converter).
91    for addr in &data.addresses {
92        let container_idx = container_map
93            .get(&addr.container_id)
94            .copied()
95            .ok_or(RuntimeError::UnresolvedDefinition(addr.container_id))?;
96        address_map.insert(addr.id, (container_idx, addr.byte_offset as usize));
97    }
98
99    // Root container is always the first entry by convention.
100    if data.containers.is_empty() {
101        return Err(RuntimeError::NoRootContainer);
102    }
103    let root_idx = 0;
104
105    let name_table = data.name_table.clone();
106
107    // Build list item map.
108    let mut list_item_map = map_with_capacity(data.list_items.len());
109    for li in &data.list_items {
110        list_item_map.insert(
111            li.id,
112            ListItemEntry {
113                name: li.name,
114                ordinal: li.ordinal,
115                origin: li.origin,
116            },
117        );
118    }
119
120    // Build list defs and list def map.
121    let mut list_defs = Vec::with_capacity(data.list_defs.len());
122    let mut list_def_map = map_with_capacity(data.list_defs.len());
123    for ldef in &data.list_defs {
124        let idx = list_defs.len();
125        // Collect all items belonging to this list, sorted by ordinal.
126        let mut items: Vec<_> = data
127            .list_items
128            .iter()
129            .filter(|li| li.origin == ldef.id)
130            .collect();
131        items.sort_by_key(|li| li.ordinal);
132        let item_ids: Vec<_> = items.iter().map(|li| li.id).collect();
133
134        list_def_map.insert(ldef.id, idx);
135        list_defs.push(ListDefEntry {
136            name: ldef.name,
137            items: item_ids,
138        });
139    }
140
141    // Clone list literals.
142    let list_literals = data.list_literals.clone();
143
144    // Clone the T1b literal pool (`PushLiteral(idx)` targets).
145    let literal_pool = data.literal_pool.clone();
146
147    // Build the TM-4 struct shape table, indexed by `ShapeId` (contiguous
148    // small-integer ids assigned at codegen time — a plain `Vec` indexed by
149    // `shape.0` mirrors `literal_pool`'s `u32`-indexed layout, no `HashMap`
150    // involved).
151    let mut struct_shapes: Vec<StructShapeEntry> = Vec::with_capacity(data.struct_shapes.len());
152    for shape in &data.struct_shapes {
153        let idx = shape.id.0 as usize;
154        if struct_shapes.len() <= idx {
155            struct_shapes.resize_with(idx + 1, || StructShapeEntry {
156                name: NameId(0),
157                fields: Vec::new(),
158            });
159        }
160        struct_shapes[idx] = StructShapeEntry {
161            name: shape.name,
162            fields: shape.fields.clone(),
163        };
164    }
165
166    // Build external function map.
167    let mut external_fns = map_with_capacity(data.externals.len());
168    for ext in &data.externals {
169        external_fns.insert(
170            ext.id,
171            ExternalFnEntry {
172                name: ext.name,
173                fallback: ext.fallback,
174            },
175        );
176    }
177
178    // Build the path → address lookup used by `Program::find_address`.
179    //
180    // When the program carries an explicit `address_paths` table (compiler
181    // output), it is the source of truth: each entry's qualified path maps to
182    // its target, resolved through `address_map`. This is what enables
183    // qualified addressing of scopes (`knot`, `knot.stitch`) and author labels
184    // (`knot.label`, `knot.stitch.label`).
185    //
186    // When the table is empty (legacy `.inkb` or converter output, which does
187    // not emit it), fall back to deriving scope paths from container names —
188    // the previous behavior, which already qualifies knot/stitch scope names.
189    let mut address_by_path: HashMap<String, PathTarget> = HashMap::new();
190    if data.address_paths.is_empty() {
191        // `BTreeMap` has no `reserve` — no-op under `no_std`.
192        #[cfg(feature = "std")]
193        address_by_path.reserve(data.containers.len());
194        for (i, cdef) in data.containers.iter().enumerate() {
195            if let Some(name_id) = cdef.name {
196                let name = resolve_name(data, name_id)?;
197                address_by_path.insert(
198                    name,
199                    PathTarget {
200                        id: cdef.id,
201                        container_idx: i as u32,
202                        byte_offset: 0,
203                    },
204                );
205            }
206        }
207    } else {
208        // `BTreeMap` has no `reserve` — no-op under `no_std`.
209        #[cfg(feature = "std")]
210        address_by_path.reserve(data.address_paths.len());
211        for ap in &data.address_paths {
212            // Resolve the target through the address map; skip anything
213            // unresolvable (defensive — should not happen for valid output).
214            if let Some(&(idx, offset)) = address_map.get(&ap.target) {
215                let name = resolve_name(data, ap.path)?;
216                address_by_path.insert(
217                    name,
218                    PathTarget {
219                        id: ap.target,
220                        container_idx: idx,
221                        byte_offset: offset,
222                    },
223                );
224            }
225        }
226    }
227
228    // Compiled `#@local` knot/stitch defaults — the base layer of policy
229    // resolution. Sorted by path so a knot expands before its stitches.
230    let mut local_scope_defaults: Vec<(String, DefinitionId)> = Vec::new();
231    for cdef in data.containers.iter().filter(|c| c.local) {
232        if let Some(n) = cdef.name {
233            local_scope_defaults.push((resolve_name(data, n)?, cdef.id));
234        }
235    }
236    local_scope_defaults.sort();
237
238    // M-2b (`docs/modules-spec.md` §4): the `#@private` definition set, used
239    // only to refuse host semantic access. Empty for the all-public world.
240    // Sorted so `Program::is_private` can binary-search (the compiler already
241    // emits it sorted; re-sort defensively for hand-built/legacy `StoryData`).
242    let mut private_defs: Vec<DefinitionId> = data.private_defs.clone();
243    private_defs.sort_by_key(|d| d.to_raw());
244
245    // M-3 (`docs/modules-spec.md` §5): the compiled alias table, sorted by
246    // `old` for `Program::resolve_alias`'s binary search. Sorted again here
247    // rather than trusted as-is — malformed/adversarial `.inkb` bytes are
248    // not guaranteed to preserve the compiler's ordering invariant.
249    let mut alias_table = data.alias_table.clone();
250    alias_table.sort_unstable();
251
252    let program = Program {
253        containers,
254        address_map,
255        scope_ids,
256        source_checksum: data.source_checksum,
257        globals,
258        global_map,
259        name_table,
260        address_by_path,
261        root_idx,
262        list_literals,
263        literal_pool,
264        list_item_map,
265        list_defs,
266        list_def_map,
267        external_fns,
268        local_scope_defaults,
269        struct_shapes,
270        private_defs,
271        alias_table,
272    };
273    Ok((program, line_tables))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    /// Regression for a fuzzer-discovered panic (`vm_no_panic`, PR #672
281    /// workstream C): a `NameId` outside `StoryData::name_table`'s range —
282    /// reachable from arbitrary/malformed `.inkb` bytes, not just
283    /// well-formed compiler output — indexed the table directly and
284    /// panicked (`index out of bounds`). Linking such a program must fail
285    /// cleanly instead.
286    fn story_with_out_of_range_address_path_name() -> StoryData {
287        let mut data = brink_compiler::compile("main.ink", |_p| {
288            Ok("=== knot ===\nHello.\n-> END\n".to_owned())
289        })
290        .unwrap()
291        .data;
292        assert!(
293            !data.address_paths.is_empty(),
294            "compiler output should carry an address_paths table"
295        );
296        data.address_paths[0].path = NameId(u16::MAX);
297        data
298    }
299
300    #[test]
301    fn link_rejects_out_of_range_address_path_name_id() {
302        let data = story_with_out_of_range_address_path_name();
303        let result = link(&data);
304        assert!(
305            matches!(result, Err(RuntimeError::InvalidNameId(id)) if id == u16::MAX),
306            "out-of-range NameId must not link"
307        );
308    }
309}