Skip to main content

weaveffi_model/
contract.rs

1//! Contract tables: a per-declaration fingerprint of each top-level module's
2//! ABI.
3//!
4//! A producer exports, for every top-level module `m`,
5//! `const {prefix}_contract_entry* {prefix}_{m}_contract(size_t* out_len)`:
6//! a static table with one [`ContractEntry`] per declaration in `m` and its
7//! submodules, sorted ascending by id. Every generated consumer checks, when
8//! it loads the library, that each entry it was generated with is present in
9//! the producer's table with an equal hash. Extra producer entries are fine,
10//! so adding a declaration never breaks a deployed binding, while removing
11//! or changing one is reported by its dotted path.
12//!
13//! [`entries`] is the one function that computes a table. The
14//! `#[weaveffi::module]` macro, the CLI, and the C header generator all call
15//! it on the validated [`Model`], so the two sides agree by construction.
16//!
17//! * The **id** is [`fnv1a64`] of the declaration's dotted path
18//!   (`kv.Store.put`, `kv.KvError`, `kv.stats.Summary`).
19//! * The **hash** is [`fnv1a64`] of a canonical signature string computed
20//!   from the resolved model, never from source spelling: type aliases are
21//!   already substituted, `[u8]` is already `bytes`, and every type is its
22//!   resolved IDL spelling (see [`Ty`]'s `Display`). Docs, deprecation text,
23//!   and error messages are excluded, and so is the order of sibling
24//!   declarations, because each entry is independent.
25//!
26//! The canonical strings are:
27//!
28//! | Declaration | Signature |
29//! |---|---|
30//! | function or interface member | `{kind} {name}({param}: {type}, ...) -> {type or void}`, then ` throws`, ` async`, ` cancellable` when set; `kind` is `function`, `constructor`, `method`, or `static` |
31//! | record | `record {name} {{field}: {type}, ...}` |
32//! | enum | `enum {name} {{Variant} = {value}, ...}`, a rich variant followed by ` {{field}: {type}, ...}` |
33//! | callback interface | `callback {name} {{method}({param}: {type}, ...) -> {type or void}; ...}`, a method followed by ` throws` when set |
34//! | error domain | `errors {name} {{Code} = {value}, ...}`, a code with fields followed by ` {{field}: {type}, ...}` |
35//! | interface | `interface {name}` (its members have their own entries) |
36
37use std::fmt::Write;
38
39use crate::model::{FieldBinding, FnBinding, Model, ModuleBinding, ParamBinding};
40use crate::ty::Ty;
41
42/// One declaration's entry in a module's contract table.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ContractEntry {
45    /// The declaration's dotted path (`kv.Store.put`), which consumers name
46    /// in their load errors.
47    pub path: String,
48    /// [`fnv1a64`] of [`path`](Self::path).
49    pub id: u64,
50    /// [`fnv1a64`] of the declaration's canonical signature string.
51    pub hash: u64,
52}
53
54/// The 64-bit FNV-1a hash of `data`: tiny, dependency-free, and stable
55/// across platforms and Rust versions.
56#[must_use]
57pub const fn fnv1a64(data: &[u8]) -> u64 {
58    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
59    let mut i = 0;
60    while i < data.len() {
61        hash ^= data[i] as u64;
62        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
63        i += 1;
64    }
65    hash
66}
67
68/// The contract table of the top-level module `root` (which must be one of
69/// `model`'s [roots](Model::roots)): one entry per declaration in `root` and
70/// its submodules, sorted ascending by id.
71#[must_use]
72pub fn entries(model: &Model, root: &ModuleBinding) -> Vec<ContractEntry> {
73    let mut out = Vec::new();
74    for m in model
75        .modules
76        .iter()
77        .filter(|m| m.segments.first() == root.segments.first())
78    {
79        module_entries(m, &mut out);
80    }
81    out.sort_by_key(|e| e.id);
82    out
83}
84
85fn entry(path: String, signature: &str) -> ContractEntry {
86    ContractEntry {
87        id: fnv1a64(path.as_bytes()),
88        hash: fnv1a64(signature.as_bytes()),
89        path,
90    }
91}
92
93fn module_entries(m: &ModuleBinding, out: &mut Vec<ContractEntry>) {
94    let dot = &m.dot_path;
95    for f in &m.functions {
96        out.push(entry(
97            format!("{dot}.{}", f.name),
98            &callable_signature("function", f),
99        ));
100    }
101    for i in &m.interfaces {
102        let owner = format!("{dot}.{}", i.name);
103        out.push(entry(owner.clone(), &format!("interface {}", i.name)));
104        for (kind, members) in [
105            ("constructor", &i.constructors),
106            ("method", &i.methods),
107            ("static", &i.statics),
108        ] {
109            for f in members {
110                out.push(entry(
111                    format!("{owner}.{}", f.name),
112                    &callable_signature(kind, f),
113                ));
114            }
115        }
116    }
117    for s in &m.structs {
118        out.push(entry(
119            format!("{dot}.{}", s.name),
120            &format!("record {} {{{}}}", s.name, fields(&s.fields)),
121        ));
122    }
123    for e in &m.enums {
124        let variants: Vec<String> = e
125            .variants
126            .iter()
127            .map(|v| case(&v.name, v.value, &v.fields))
128            .collect();
129        out.push(entry(
130            format!("{dot}.{}", e.name),
131            &format!("enum {} {{{}}}", e.name, variants.join(", ")),
132        ));
133    }
134    for c in &m.callback_interfaces {
135        let methods: Vec<String> = c
136            .methods
137            .iter()
138            .map(|cm| {
139                let mut sig = format!(
140                    "{}({}) -> {}",
141                    cm.name,
142                    params(&cm.params),
143                    ret(cm.ret.as_ref())
144                );
145                if cm.throws {
146                    sig.push_str(" throws");
147                }
148                sig
149            })
150            .collect();
151        out.push(entry(
152            format!("{dot}.{}", c.name),
153            &format!("callback {} {{{}}}", c.name, methods.join("; ")),
154        ));
155    }
156    if let Some(d) = &m.errors {
157        let codes: Vec<String> = d
158            .codes
159            .iter()
160            .map(|c| case(&c.name, c.value, &c.fields))
161            .collect();
162        out.push(entry(
163            format!("{dot}.{}", d.name),
164            &format!("errors {} {{{}}}", d.name, codes.join(", ")),
165        ));
166    }
167}
168
169fn params(params: &[ParamBinding]) -> String {
170    let all: Vec<String> = params
171        .iter()
172        .map(|p| format!("{}: {}", p.name, p.ty))
173        .collect();
174    all.join(", ")
175}
176
177fn fields(fields: &[FieldBinding]) -> String {
178    let all: Vec<String> = fields
179        .iter()
180        .map(|f| format!("{}: {}", f.name, f.ty))
181        .collect();
182    all.join(", ")
183}
184
185fn ret(ty: Option<&Ty>) -> String {
186    ty.map_or_else(|| "void".to_string(), ToString::to_string)
187}
188
189/// `Name = value`, followed by ` {fields}` when the case carries any.
190fn case(name: &str, value: i32, case_fields: &[FieldBinding]) -> String {
191    let mut out = format!("{name} = {value}");
192    if !case_fields.is_empty() {
193        let _ = write!(out, " {{{}}}", fields(case_fields));
194    }
195    out
196}
197
198fn callable_signature(kind: &str, f: &FnBinding) -> String {
199    let mut sig = format!(
200        "{kind} {}({}) -> {}",
201        f.name,
202        params(&f.params),
203        ret(f.ret.as_ref())
204    );
205    if f.throws {
206        sig.push_str(" throws");
207    }
208    if f.is_async() {
209        sig.push_str(" async");
210    }
211    if f.cancellable {
212        sig.push_str(" cancellable");
213    }
214    sig
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::ir::Api;
221    use crate::pkg::Identity;
222
223    fn model(yaml: &str) -> Model {
224        let api: Api = serde_yaml::from_str(yaml).unwrap();
225        crate::validate::validate(&api, &Identity::named("kv"), None).unwrap()
226    }
227
228    const KV: &str = r#"
229version: "0.11.0"
230modules:
231  - name: kv
232    doc: Key-value storage.
233    errors:
234      name: KvError
235      codes:
236        - { name: NotFound, code: 1, message: missing, fields: [{ name: key, type: string }] }
237    structs:
238      - name: Entry
239        fields: [{ name: key, type: string }, { name: value, type: bytes }]
240    interfaces:
241      - name: Store
242        constructors: [{ name: open, params: [{ name: path, type: string }], throws: true }]
243        methods:
244          - { name: get, params: [{ name: key, type: string }], return: "bytes?", throws: true }
245    callback_interfaces:
246      - name: Listener
247        methods:
248          - { name: on_put, params: [{ name: entry, type: Entry }], return: string, throws: true }
249    functions:
250      - { name: version, params: [], return: string, doc: The version. }
251    modules:
252      - name: stats
253        enums:
254          - name: Kind
255            variants: [{ name: Hot, value: 0 }, { name: Cold, value: 1 }]
256"#;
257
258    fn paths(model: &Model) -> Vec<String> {
259        let root = model.roots().next().unwrap();
260        entries(model, root).into_iter().map(|e| e.path).collect()
261    }
262
263    #[test]
264    fn one_entry_per_declaration_sorted_by_id() {
265        let model = model(KV);
266        let root = model.roots().next().unwrap();
267        let table = entries(&model, root);
268        let mut got = paths(&model);
269        got.sort();
270        assert_eq!(
271            got,
272            [
273                "kv.Entry",
274                "kv.KvError",
275                "kv.Listener",
276                "kv.Store",
277                "kv.Store.get",
278                "kv.Store.open",
279                "kv.stats.Kind",
280                "kv.version"
281            ]
282        );
283        assert!(table.windows(2).all(|w| w[0].id < w[1].id));
284        let get = table.iter().find(|e| e.path == "kv.Store.get").unwrap();
285        assert_eq!(get.id, fnv1a64(b"kv.Store.get"));
286        assert_eq!(
287            get.hash,
288            fnv1a64(b"method get(key: string) -> bytes? throws")
289        );
290        let listener = table.iter().find(|e| e.path == "kv.Listener").unwrap();
291        assert_eq!(
292            listener.hash,
293            fnv1a64(b"callback Listener {on_put(entry: Entry) -> string throws}")
294        );
295    }
296
297    fn hash_of(model: &Model, path: &str) -> u64 {
298        let root = model.roots().next().unwrap();
299        entries(model, root)
300            .into_iter()
301            .find(|e| e.path == path)
302            .unwrap()
303            .hash
304    }
305
306    #[test]
307    fn prose_and_sibling_order_are_excluded() {
308        let a = model(KV);
309        let edited = KV
310            .replace("doc: The version.", "doc: Changed prose.")
311            .replace("message: missing", "message: gone");
312        let b = model(&edited);
313        assert_eq!(
314            entries(&a, a.roots().next().unwrap()),
315            entries(&b, b.roots().next().unwrap())
316        );
317        // Swapping two variants changes the enum (its own order is part of
318        // the signature) but not its neighbors.
319        let swapped = KV.replace(
320            "[{ name: Hot, value: 0 }, { name: Cold, value: 1 }]",
321            "[{ name: Cold, value: 1 }, { name: Hot, value: 0 }]",
322        );
323        let c = model(&swapped);
324        assert_ne!(hash_of(&a, "kv.stats.Kind"), hash_of(&c, "kv.stats.Kind"));
325        assert_eq!(hash_of(&a, "kv.Entry"), hash_of(&c, "kv.Entry"));
326    }
327
328    #[test]
329    fn abi_changes_change_only_their_entry() {
330        let a = model(KV);
331        let b = model(&KV.replace(
332            "return: \"bytes?\", throws: true",
333            "return: bytes, throws: true",
334        ));
335        assert_ne!(hash_of(&a, "kv.Store.get"), hash_of(&b, "kv.Store.get"));
336        assert_eq!(hash_of(&a, "kv.Store.open"), hash_of(&b, "kv.Store.open"));
337        assert_eq!(hash_of(&a, "kv.Store"), hash_of(&b, "kv.Store"));
338    }
339
340    #[test]
341    fn fnv_is_pinned() {
342        // Changing the algorithm invalidates every deployed binding, so it
343        // must be a deliberate, documented ABI change.
344        assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
345        assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
346    }
347}