1use 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#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Manifest {
17 pub base_url: String,
20 #[serde(default)]
22 pub prefix: String,
23 pub size: u64,
25 #[serde(default)]
26 pub image: Vec<Image>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct Image {
33 pub name: String,
35 pub version: Version,
37 pub pcb: Option<String>,
39 pub kind: String,
41 #[serde(default)]
43 pub chips: Vec<String>,
44 pub sha256: String,
46}
47
48impl Manifest {
49 #[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 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 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#[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
115pub 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#[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 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 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}