Skip to main content

rhdl_fpga_planner/
build_apply.rs

1//! Validate the small set of uBFT settings that currently reach real hardware.
2//!
3//! A saved planner model is not a bitstream. This boundary deliberately rejects
4//! absent fields and unknown knobs instead of silently building defaults. Hash
5//! algorithms belong to a named cryptographic profile, not a throughput knob.
6use std::collections::BTreeMap;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9/// Signature implementation selected by the Cargo feature and exporter.
10pub enum Scheme {
11    /// Ed25519 with the SHA-512 RUB2 message layout.
12    Ed25519,
13    /// Experimental Haraka chains and Blake3 messages; message size is explicit.
14    Dsig,
15}
16impl Scheme {
17    /// Stable Cargo feature and build-script selection name.
18    pub fn name(self) -> &'static str {
19        match self {
20            Self::Ed25519 => "ed25519",
21            Self::Dsig => "dsig",
22        }
23    }
24    /// Exact profile identifier accepted by this application boundary.
25    pub fn profile(self) -> &'static str {
26        match self {
27            Self::Ed25519 => "ED25519_SHA512_RUB2",
28            Self::Dsig => "DSIG_FPGA_W4_HARAKA_BLAKE3_V1",
29        }
30    }
31}
32
33/// Compile-time settings supported by the replica exporter. Capacity is not
34/// runtime membership: the protocol separately validates an odd active size.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct UbftBuild {
37    /// Signature service to instantiate.
38    pub scheme: Scheme,
39    /// Hardware peer capacity, separate from runtime membership.
40    pub nodes: u32,
41    /// Number of protocol slots instantiated in the replica.
42    pub window: u32,
43    /// Ed25519 field-machine count; DSig currently requires one.
44    pub verify_machines: u32,
45    /// DSig engine slice geometry; neutral value two for Ed25519.
46    pub dsig_slices: u32,
47    /// Authenticated DSig message bytes: 64 by default, or explicitly gated 16.
48    pub dsig_message_bytes: u32,
49    /// Explicit opt-in to the narrower experimental uBFT message contract.
50    pub dsig_allow_profile1_ubft: bool,
51}
52impl UbftBuild {
53    /// Reject unsupported geometry before any compiler is launched.
54    pub fn validate(&self) -> Result<(), String> {
55        if !matches!(self.dsig_message_bytes, 16 | 64) {
56            return Err("dsig_message_bytes must be 16 or 64".into());
57        }
58        if self.scheme == Scheme::Dsig && self.dsig_message_bytes == 16 && !self.dsig_allow_profile1_ubft {
59            return Err("16-byte DSig uBFT requires explicit dsig_allow_profile1_ubft; full context requires 64 bytes".into());
60        }
61        if self.scheme == Scheme::Ed25519 && (self.dsig_message_bytes != 64 || self.dsig_allow_profile1_ubft) {
62            return Err("DSig message controls cannot tune Ed25519; use neutral 64 and false".into());
63        }
64        let max_nodes = if self.scheme == Scheme::Dsig { 8 } else { 16 };
65        if !(3..=max_nodes).contains(&self.nodes) {
66            return Err(format!("nodes must be 3..={max_nodes} for {} key capacity", self.scheme.name()));
67        }
68        if !(1..=8).contains(&self.window) {
69            return Err("window must be 1..=8".into());
70        }
71        if !(1..=4).contains(&self.verify_machines) {
72            return Err("verify_machines must be 1..=4".into());
73        }
74        if self.scheme == Scheme::Dsig && self.verify_machines != 1 {
75            return Err("DSig's current uBFT service has one verifier; this knob is not wired there".into());
76        }
77        if !matches!(self.dsig_slices, 2 | 4 | 8) {
78            return Err("dsig_slices must be 2, 4 or 8 (6 has no supported Merkle geometry)".into());
79        }
80        if self.scheme == Scheme::Ed25519 && self.dsig_slices != 2 {
81            return Err("dsig_slices cannot tune an Ed25519 build; leave the neutral value 2".into());
82        }
83        Ok(())
84    }
85    /// Read only the explicit application contract. Other component tables are
86    /// explanatory planner output, not extra silently applied hardware knobs.
87    pub fn from_toml(text: &str) -> Result<Self, String> {
88        let doc: toml::Table = text.parse().map_err(|e| format!("invalid TOML: {e}"))?;
89        let table = doc
90            .get("rhdl_build")
91            .and_then(toml::Value::as_table)
92            .ok_or("missing [rhdl_build]; this legacy/model-only plan cannot be applied")?;
93        const KEYS: &[&str] = &[
94            "schema",
95            "scheme",
96            "profile",
97            "nodes",
98            "window",
99            "verify_machines",
100            "dsig_slices",
101            "dsig_message_bytes",
102            "dsig_allow_profile1_ubft",
103            "buildable",
104        ];
105        for key in table.keys() {
106            if !KEYS.contains(&key.as_str()) {
107                return Err(format!("unsupported build knob {key}"));
108            }
109        }
110        let number = |key: &str| -> Result<u32, String> {
111            table
112                .get(key)
113                .and_then(toml::Value::as_integer)
114                .and_then(|n| u32::try_from(n).ok())
115                .ok_or_else(|| format!("{key} must be an explicit nonnegative integer"))
116        };
117        if number("schema")? != 1 {
118            return Err("unsupported build schema".into());
119        }
120        if table.get("buildable").and_then(toml::Value::as_bool) != Some(true) {
121            return Err("plan has missing or unsupported components; buildable is not true".into());
122        }
123        let scheme = match table.get("scheme").and_then(toml::Value::as_str) {
124            Some("ed25519") => Scheme::Ed25519,
125            Some("dsig") => Scheme::Dsig,
126            _ => return Err("scheme must be ed25519 or dsig".into()),
127        };
128        if table.get("profile").and_then(toml::Value::as_str) != Some(scheme.profile()) {
129            return Err("profile/hash substitution is unsupported; select the validated scheme profile".into());
130        }
131        let build = Self {
132            scheme,
133            nodes: number("nodes")?,
134            window: number("window")?,
135            verify_machines: number("verify_machines")?,
136            dsig_slices: number("dsig_slices")?,
137            dsig_message_bytes: number("dsig_message_bytes")?,
138            dsig_allow_profile1_ubft: table
139                .get("dsig_allow_profile1_ubft")
140                .and_then(toml::Value::as_bool)
141                .ok_or("dsig_allow_profile1_ubft must be an explicit boolean")?,
142        };
143        build.validate()?;
144        Ok(build)
145    }
146    /// Serialize the application contract, retaining an explicit validity flag.
147    pub fn toml(&self, buildable: bool) -> String {
148        format!(
149            "\n# buildable means supported compilation, not measured card fit.\n[rhdl_build]\nschema = 1\nscheme = {:?}\nprofile = {:?}\nnodes = {}\nwindow = {}\nverify_machines = {}\ndsig_slices = {}\ndsig_message_bytes = {}\ndsig_allow_profile1_ubft = {}\nbuildable = {}\n",
150            self.scheme.name(),
151            self.scheme.profile(),
152            self.nodes,
153            self.window,
154            self.verify_machines,
155            self.dsig_slices,
156            self.dsig_message_bytes,
157            self.dsig_allow_profile1_ubft,
158            buildable && self.validate().is_ok()
159        )
160    }
161    /// Clear inherited experimental settings before applying this exact plan.
162    /// The caller supplies inherited names so tests do not mutate global env.
163    pub fn configure_environment(
164        &self,
165        command: &mut std::process::Command,
166        inherited: impl IntoIterator<Item = std::ffi::OsString>,
167    ) -> Result<(), String> {
168        for key in inherited {
169            let name = key.to_string_lossy();
170            if name.starts_with("RHDL_UBFT_") || name.starts_with("ED25519_") || name.starts_with("DSIG_") {
171                command.env_remove(key);
172            }
173        }
174        command.envs(self.environment()?);
175        command.env("RHDL_UBFT_APPLIED_CONFIG", self.resolved_toml()?);
176        Ok(())
177    }
178    /// Canonical selection and complete resolved project environment. Record
179    /// this alongside the artifact; external evidence tooling hashes the file.
180    pub fn resolved_toml(&self) -> Result<String, String> {
181        let mut text = self.toml(true);
182        text.push_str("\n[cargo]\ndefault_features = false\n");
183        text.push_str(&format!("features = [{:?}]\n\n[environment]\n", self.scheme.name()));
184        for (key, value) in self.environment()? {
185            text.push_str(&format!("{key} = {value:?}\n"));
186        }
187        Ok(text)
188    }
189    /// Pass these as process environment entries, never interpolate a shell.
190    pub fn environment(&self) -> Result<BTreeMap<String, String>, String> {
191        self.validate()?;
192        let mut env = BTreeMap::from([
193            ("RHDL_UBFT_SCHEME".into(), self.scheme.name().into()),
194            ("RHDL_UBFT_NODES".into(), self.nodes.to_string()),
195            ("RHDL_UBFT_WINDOW".into(), self.window.to_string()),
196            ("RHDL_UBFT_VERIFY_MACHINES".into(), self.verify_machines.to_string()),
197            ("ED25519_MULTIPLIER".into(), "DSP225".into()),
198            ("ED25519_SCALABLE_PROFILE".into(), "15,11,3,1".into()),
199            ("ED25519_VERIFY_PROFILE".into(), "2,1,1,1".into()),
200            ("ED25519_URAM_MACHINES".into(), usize::MAX.to_string()),
201            ("ED25519_LUT_MACHINES".into(), "0".into()),
202        ]);
203        if self.scheme == Scheme::Dsig {
204            // The DSig msg64 build consumer now distinguishes two profiles.
205            // The saved choice must reach the backend unchanged, regardless of
206            // ambient experiments with a different authenticated message size.
207            env.extend([
208                ("DSIG_CHAIN".into(), "haraka".into()),
209                ("DSIG_MESSAGE_BYTES".into(), self.dsig_message_bytes.to_string()),
210                ("DSIG_SLICES".into(), self.dsig_slices.to_string()),
211                ("DSIG_VERIFY_UNITS".into(), "1".into()),
212                ("DSIG_TABLE_MASK".into(), "0x78".into()),
213                ("DSIG_SBOX_PLACEMENT".into(), "uram=0,bram=0".into()),
214            ]);
215        }
216        Ok(env)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    fn build() -> UbftBuild {
224        UbftBuild {
225            scheme: Scheme::Ed25519,
226            nodes: 3,
227            window: 3,
228            verify_machines: 1,
229            dsig_slices: 2,
230            dsig_message_bytes: 64,
231            dsig_allow_profile1_ubft: false,
232        }
233    }
234    #[test]
235    fn roundtrip_and_environment_carry_every_supported_knob() {
236        for nodes in 3..=16 {
237            for window in 1..=8 {
238                for verify_machines in 1..=4 {
239                    let b = UbftBuild {
240                        nodes,
241                        window,
242                        verify_machines,
243                        ..build()
244                    };
245                    assert_eq!(UbftBuild::from_toml(&b.toml(true)).unwrap(), b);
246                    let env = b.environment().unwrap();
247                    assert_eq!(env["RHDL_UBFT_NODES"], nodes.to_string());
248                    assert_eq!(env["RHDL_UBFT_WINDOW"], window.to_string());
249                    assert_eq!(env["RHDL_UBFT_VERIFY_MACHINES"], verify_machines.to_string());
250                }
251            }
252        }
253        for dsig_slices in [2, 4, 8] {
254            let b = UbftBuild {
255                scheme: Scheme::Dsig,
256                nodes: 8,
257                dsig_slices,
258                ..build()
259            };
260            assert_eq!(UbftBuild::from_toml(&b.toml(true)).unwrap(), b);
261            let env = b.environment().unwrap();
262            assert_eq!(env["DSIG_SLICES"], dsig_slices.to_string());
263            assert_eq!(env["DSIG_MESSAGE_BYTES"], "64", "full context is the default");
264        }
265    }
266    #[test]
267    fn narrower_message_contract_requires_an_explicit_opt_in() {
268        let full = UbftBuild {
269            scheme: Scheme::Dsig,
270            ..build()
271        };
272        for bytes in [16, 64] {
273            let selected = UbftBuild {
274                dsig_message_bytes: bytes,
275                dsig_allow_profile1_ubft: bytes == 16,
276                ..full.clone()
277            };
278            assert_eq!(UbftBuild::from_toml(&selected.toml(true)).unwrap(), selected);
279        }
280        let good = full.toml(true);
281        for bad in [
282            good.replace("dsig_message_bytes = 64", "dsig_message_bytes = 16"),
283            good.replace("dsig_message_bytes = 64", "dsig_message_bytes = 32"),
284            good.replace("dsig_message_bytes = 64", "dsig_message_bytes = -1"),
285            good.replace("dsig_message_bytes = 64\n", ""),
286            good.replace("dsig_allow_profile1_ubft = false\n", ""),
287            good.replace("dsig_allow_profile1_ubft = false", "dsig_allow_profile1_ubft = \"false\""),
288        ] {
289            assert!(UbftBuild::from_toml(&bad).is_err(), "accepted {bad}");
290        }
291        let rejected = UbftBuild {
292            dsig_message_bytes: 16,
293            ..full
294        };
295        assert!(rejected.environment().is_err());
296        assert!(rejected.toml(true).contains("buildable = false"));
297    }
298
299    #[test]
300    fn invalid_or_unsupported_settings_fail_closed() {
301        let good = build().toml(true);
302        for text in [
303            good.replace("nodes = 3", "nodes = 17"),
304            good.replace("window = 3", "window = 0"),
305            good.replace("verify_machines = 1", "verify_machines = 5"),
306            good.replace("ED25519_SHA512_RUB2", "SHA256"),
307            good.replace("schema = 1", "schema = 2"),
308            good.replace("buildable = true", "buildable = false"),
309            format!("{good}queue_depth = 8\n"),
310            good.replace("nodes = 3\n", ""),
311            good.replace("nodes = 3", "nodes = -1"),
312            good.replace("nodes = 3", "nodes = 3.5"),
313        ] {
314            assert!(UbftBuild::from_toml(&text).is_err(), "accepted {text}");
315        }
316        assert!(UbftBuild::from_toml("[legacy]\ncores = 8\n").is_err());
317        for b in [
318            UbftBuild {
319                scheme: Scheme::Dsig,
320                nodes: 9,
321                ..build()
322            },
323            UbftBuild {
324                scheme: Scheme::Dsig,
325                verify_machines: 2,
326                ..build()
327            },
328            UbftBuild {
329                scheme: Scheme::Dsig,
330                dsig_slices: 6,
331                ..build()
332            },
333        ] {
334            assert!(b.validate().is_err());
335        }
336    }
337}
338
339#[cfg(test)]
340mod environment_contract {
341    use super::*;
342    #[test]
343    fn explicit_dsig_message_size_overrides_both_inherited_profiles() {
344        for (selected, inherited, allowed) in [(64, "16", false), (16, "64", true)] {
345            let build = UbftBuild {
346                scheme: Scheme::Dsig,
347                nodes: 3,
348                window: 2,
349                verify_machines: 1,
350                dsig_slices: 2,
351                dsig_message_bytes: selected,
352                dsig_allow_profile1_ubft: allowed,
353            };
354            let mut command = std::process::Command::new("unused");
355            command.env("DSIG_MESSAGE_BYTES", inherited);
356            build.configure_environment(&mut command, ["DSIG_MESSAGE_BYTES".into()]).unwrap();
357            let applied = command.get_envs().find(|(key, _)| *key == "DSIG_MESSAGE_BYTES").unwrap().1.unwrap();
358            assert_eq!(applied, selected.to_string().as_str());
359            assert_eq!(UbftBuild::from_toml(&build.resolved_toml().unwrap()).unwrap(), build);
360            let altered = build
361                .toml(true)
362                .replace("DSIG_FPGA_W4_HARAKA_BLAKE3_V1", "DSIG_FPGA_W4_HARAKA_BLAKE3_V2");
363            assert!(UbftBuild::from_toml(&altered).is_err());
364        }
365    }
366    #[test]
367    fn ambient_conflicts_are_removed_and_supported_values_replaced() {
368        let build = UbftBuild {
369            scheme: Scheme::Ed25519,
370            nodes: 3,
371            window: 2,
372            verify_machines: 4,
373            dsig_slices: 2,
374            dsig_message_bytes: 64,
375            dsig_allow_profile1_ubft: false,
376        };
377        let mut command = std::process::Command::new("unused");
378        command
379            .env("ED25519_SCALABLE_PROFILE", "99,99,99,99")
380            .env("DSIG_CHAIN", "sha512")
381            .env("RHDL_UBFT_SCHEME", "dsig");
382        build
383            .configure_environment(
384                &mut command,
385                ["ED25519_SCALABLE_PROFILE", "DSIG_CHAIN", "RHDL_UBFT_SCHEME", "DSIG_UNKNOWN", "PATH"].map(Into::into),
386            )
387            .unwrap();
388        let edits: BTreeMap<_, _> = command
389            .get_envs()
390            .map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(|x| x.to_string_lossy().into_owned())))
391            .collect();
392        assert_eq!(edits["DSIG_CHAIN"], None);
393        assert_eq!(edits["DSIG_UNKNOWN"], None);
394        assert_eq!(edits["RHDL_UBFT_SCHEME"].as_deref(), Some("ed25519"));
395        assert_eq!(edits["ED25519_SCALABLE_PROFILE"].as_deref(), Some("15,11,3,1"));
396        assert!(!edits.contains_key("PATH"));
397    }
398}