Skip to main content

receivers/
firmware.rs

1//! The firmware manifest, `config/firmware.toml`: the vendor images under
2//! `third-party/firmware` by name, version, kind, size and sha256. Embedded
3//! at build time like the card models.
4
5use crate::Version;
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8use std::fmt::Write as _;
9use std::sync::OnceLock;
10
11const TEXT: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config/firmware.toml"));
12
13/// The manifest as a whole.
14#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Manifest {
17    /// The asset host `fetch` downloads `base_url/prefix/<file>` from; empty
18    /// means local only.
19    pub base_url: String,
20    /// Object key prefix the images sit under.
21    #[serde(default)]
22    pub prefix: String,
23    /// Every image is this many bytes.
24    pub size: u64,
25    #[serde(default)]
26    pub image: Vec<Image>,
27}
28
29/// One firmware image.
30#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct Image {
33    /// The file name, and what commands take.
34    pub name: String,
35    /// What the card reports after the install.
36    pub version: Version,
37    /// The board revision in the file name; absent when the name carries none.
38    pub pcb: Option<String>,
39    /// The vendor's build variant: `PWM`, `Normal`, `LS0allDA`.
40    pub kind: String,
41    /// Driver chips the file name lists; empty when it lists none.
42    #[serde(default)]
43    pub chips: Vec<String>,
44    /// Lowercase hex.
45    pub sha256: String,
46}
47
48impl Manifest {
49    /// The object key of an image: the prefix and the file name, with any
50    /// character an object key cannot carry replaced by an underscore.
51    #[must_use]
52    pub fn path(&self, image: &Image) -> String {
53        let file: String = image
54            .name
55            .chars()
56            .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
57            .collect();
58        // Runs of replaced characters collapse, as the uploaded keys do.
59        let mut key = String::with_capacity(file.len());
60        for c in file.chars() {
61            if c == '_' && key.ends_with('_') {
62                continue;
63            }
64            key.push(c);
65        }
66        format!("{}/{key}", self.prefix.trim_end_matches('/'))
67    }
68}
69
70impl Image {
71    /// Check `bytes` against the manifest's size and sha256.
72    ///
73    /// # Errors
74    /// Names the field that disagrees, expected and found.
75    pub fn verify(&self, bytes: &[u8]) -> Result<(), String> {
76        let want = manifest().size;
77        if bytes.len() as u64 != want {
78            return Err(format!("{}: size {} bytes, manifest says {want}", self.name, bytes.len()));
79        }
80        let got = sha256_hex(bytes);
81        if got != self.sha256 {
82            return Err(format!(
83                "{}: sha256 {got}, manifest says {}",
84                self.name, self.sha256
85            ));
86        }
87        Ok(())
88    }
89}
90
91/// The sha256 of `bytes` as lowercase hex.
92#[must_use]
93pub fn sha256_hex(bytes: &[u8]) -> String {
94    let digest = Sha256::digest(bytes);
95    let mut s = String::with_capacity(64);
96    for b in digest {
97        let _ = write!(s, "{b:02x}");
98    }
99    s
100}
101
102fn parse(text: &str) -> Result<Manifest, String> {
103    let m: Manifest = toml::from_str(text).map_err(|e| format!("config/firmware.toml: {e}"))?;
104    for (i, img) in m.image.iter().enumerate() {
105        if img.sha256.len() != 64 || !img.sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
106            return Err(format!("config/firmware.toml: {}: sha256 is not 64 lowercase hex digits", img.name));
107        }
108        if m.image[..i].iter().any(|o| o.name == img.name) {
109            return Err(format!("config/firmware.toml: {}: listed twice", img.name));
110        }
111    }
112    Ok(m)
113}
114
115/// The embedded manifest.
116///
117/// # Panics
118/// When `config/firmware.toml` does not parse; the tests catch that first.
119pub fn manifest() -> &'static Manifest {
120    static MANIFEST: OnceLock<Manifest> = OnceLock::new();
121    MANIFEST.get_or_init(|| parse(TEXT).unwrap_or_else(|e| panic!("{e}")))
122}
123
124/// The image called `name`, exactly.
125#[must_use]
126pub fn image(name: &str) -> Option<&'static Image> {
127    manifest().image.iter().find(|i| i.name == name)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::path::Path;
134
135    fn repo(rel: &str) -> std::path::PathBuf {
136        Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(rel)
137    }
138
139    #[test]
140    fn the_manifest_lists_every_archived_image_and_the_hashes_match() {
141        let m = manifest();
142        assert!(m.base_url.is_empty() || m.base_url.starts_with("https://"));
143        let dir = repo("third-party/firmware");
144        // The images are not in the repository; the check runs where a local archive exists.
145        let Ok(entries) = std::fs::read_dir(&dir) else {
146            eprintln!("skipped: no local firmware archive at {}", dir.display());
147            return;
148        };
149        let on_disk: Vec<String> = entries
150            .flatten()
151            .map(|e| e.file_name().to_string_lossy().into_owned())
152            .filter(|n| Path::new(n).extension().is_some_and(|x| x == "hex"))
153            .collect();
154        // Every locally archived image is listed, and its bytes match the entry.
155        for name in &on_disk {
156            let img = image(name).unwrap_or_else(|| panic!("{name}: not in the manifest"));
157            let bytes = std::fs::read(dir.join(name)).expect("read image");
158            assert_eq!(img.verify(&bytes), Ok(()), "{name}");
159        }
160    }
161
162    #[test]
163    fn names_resolve_exactly() {
164        let img = image("E320_PWM_FPGA16.53_20231227_SM16386S_SM16269SH.hex").expect("16.53 listed");
165        assert_eq!(img.version, Version(16, 53));
166        assert_eq!(img.kind, "PWM");
167        assert_eq!(img.chips, ["SM16386S", "SM16269SH"]);
168        assert_eq!(img.pcb, None);
169        assert_eq!(
170            image("E320_PCB6.1_LS0allDA_FPGA6.69_20220907.hex").map(|i| i.pcb.as_deref()),
171            Some(Some("6.1"))
172        );
173        assert!(image("e320_pwm_fpga16.53_20231227_sm16386s_sm16269sh.hex").is_none());
174        assert!(image("third-party/firmware/E320_PWM_FPGA16.53_20231227_SM16386S_SM16269SH.hex").is_none());
175    }
176
177    #[test]
178    fn a_wrong_hash_or_size_is_refused() {
179        let bytes = vec![0xA5u8; manifest().size as usize];
180        let img = Image {
181            name: "synthetic.hex".into(),
182            version: Version(1, 0),
183            pcb: None,
184            kind: "PWM".into(),
185            chips: Vec::new(),
186            sha256: sha256_hex(&bytes),
187        };
188        assert_eq!(img.verify(&bytes), Ok(()));
189        let mut other = bytes.clone();
190        other[0] = 0x5A;
191        assert!(img.verify(&other).unwrap_err().contains("sha256"));
192        assert!(img.verify(&bytes[..bytes.len() - 1]).unwrap_err().contains("size"));
193    }
194
195    #[test]
196    fn a_malformed_manifest_is_refused() {
197        let bad = "base_url = \"\"\nprefix = \"f\"\nsize = 1\nimage = [ { name = \"x.hex\", version = \"1.0\", kind = \"PWM\", sha256 = \"abc\" } ]\n";
198        assert!(parse(bad).unwrap_err().contains("64 lowercase hex"));
199        let twice = TEXT.trim_end().trim_end_matches(']').to_string()
200            + "  { name = \"E320_PCB6.0_PWM_FPGA9.53_20221031.hex\", version = \"9.53\", kind = \"PWM\", sha256 = \"cb7c264231d7123bbf3fba4a9ec964a410b20e284db5715e46f50da0eeaffa19\" },\n]\n";
201        assert!(parse(&twice).unwrap_err().contains("listed twice"));
202    }
203}