Skip to main content

fidius_guest/
hash.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! FNV-1a interface hashing for compile-time ABI drift detection.
16//!
17//! The proc macro computes an `interface_hash` from the sorted required method
18//! signatures of a trait. The host checks this hash at load time to reject
19//! plugins compiled against a different interface.
20
21/// FNV-1a 64-bit offset basis.
22const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
23
24/// FNV-1a 64-bit prime.
25const FNV_PRIME: u64 = 0x100000001b3;
26
27/// Compute the FNV-1a 64-bit hash of a byte slice.
28pub const fn fnv1a(bytes: &[u8]) -> u64 {
29    let mut hash = FNV_OFFSET_BASIS;
30    let mut i = 0;
31    while i < bytes.len() {
32        hash ^= bytes[i] as u64;
33        hash = hash.wrapping_mul(FNV_PRIME);
34        i += 1;
35    }
36    hash
37}
38
39/// Compute the interface hash from a set of method signatures.
40///
41/// Signatures are sorted lexicographically before hashing to ensure
42/// order-independence. Each signature is joined with `\n` as a separator.
43///
44/// This function is **not** `const` because it allocates for sorting.
45/// The proc macro calls this at compile time via a build-script-like pattern,
46/// or uses `fnv1a` directly on pre-sorted, concatenated signatures.
47pub fn interface_hash(signatures: &[&str]) -> u64 {
48    let mut sorted: Vec<&str> = signatures.to_vec();
49    sorted.sort();
50    let combined = sorted.join("\n");
51    fnv1a(combined.as_bytes())
52}
53
54/// Build the canonical signature string for one method.
55///
56/// Format: `"{name}:{arg_type_1},{arg_type_2}->{return_type}{!raw?}"`.
57///
58/// - `arg_types` are pre-stringified (typically by `syn::Type` →
59///   `to_token_stream().to_string()` — the proc macro and any other
60///   tooling that wants to compute the same hash must use the same
61///   formatter).
62/// - `return_type` is the stringified return type, or empty string for
63///   methods returning `()`.
64/// - `wire_raw = true` appends a trailing `!raw` marker so methods opted
65///   into raw wire mode hash differently from bincode-typed methods of
66///   the same Rust signature. This is the protection that makes a
67///   wire-mode mismatch surface as a load-time hash mismatch instead of
68///   silent data corruption.
69/// - `streaming = true` appends a trailing `!stream` marker (after any
70///   `!raw`) so a server-streaming method (`-> fidius::Stream<T>`, where
71///   `ret` is the per-item type `T`) hashes differently from a unary
72///   `-> T` method of the same name/args. Same protection as `!raw`, for
73///   the streaming/unary split (FIDIUS-I-0026, D4).
74///
75/// This function lives in `fidius-core` (not `fidius-macro`) so the proc
76/// macro and downstream tooling like `fidius python-stub` share a single
77/// source of truth for the format. Drift between them = silent hash
78/// mismatch, which is exactly what the load-time check is meant to catch
79/// — but better to never have the drift in the first place.
80pub fn signature_string(
81    name: &str,
82    arg_types: &[String],
83    ret: &str,
84    wire_raw: bool,
85    streaming: bool,
86) -> String {
87    let raw_marker = if wire_raw { "!raw" } else { "" };
88    let stream_marker = if streaming { "!stream" } else { "" };
89    format!(
90        "{}:{}->{}{}{}",
91        name,
92        arg_types.join(","),
93        ret,
94        raw_marker,
95        stream_marker
96    )
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn empty_input() {
105        // Empty string should produce the offset basis XOR'd with nothing,
106        // which is just the offset basis.
107        assert_eq!(fnv1a(b""), FNV_OFFSET_BASIS);
108    }
109
110    #[test]
111    fn known_vector() {
112        // FNV-1a("fidius") — precomputed reference value
113        let hash = fnv1a(b"fidius");
114        // Just verify it's deterministic and non-zero
115        assert_ne!(hash, 0);
116        assert_eq!(hash, fnv1a(b"fidius"));
117    }
118
119    #[test]
120    fn order_independence() {
121        let a = interface_hash(&[
122            "process:&[u8],Value->Result<Vec<u8>,PluginError>",
123            "name:->String",
124        ]);
125        let b = interface_hash(&[
126            "name:->String",
127            "process:&[u8],Value->Result<Vec<u8>,PluginError>",
128        ]);
129        assert_eq!(a, b);
130    }
131
132    #[test]
133    fn sensitivity() {
134        let a = interface_hash(&["name:->String"]);
135        let b = interface_hash(&["name:->string"]); // lowercase 's'
136        assert_ne!(a, b);
137    }
138
139    #[test]
140    fn different_signatures_differ() {
141        let a = interface_hash(&["foo:->i32"]);
142        let b = interface_hash(&["bar:->i32"]);
143        let c = interface_hash(&["foo:->u32"]);
144        assert_ne!(a, b);
145        assert_ne!(a, c);
146        assert_ne!(b, c);
147    }
148}