1use serde::Deserialize;
11
12use super::component_problem;
13use super::exclude::list_problems;
14use super::yamlio::{SchemaVersion, strict_parse};
15use crate::error::{CoreError, DeployFinding};
16
17#[derive(Debug, Clone, PartialEq, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Manifest {
21 pub schema_version: SchemaVersion,
23 #[serde(default)]
26 pub standards_version: Option<String>,
27 pub types: Vec<String>,
30 #[serde(default)]
32 pub tag_providers: Vec<String>,
33 #[serde(default)]
39 pub exclude_tag_providers: Vec<String>,
40 #[serde(default)]
45 pub read_only_tag_providers: Vec<String>,
46 #[serde(default)]
48 pub projects: Vec<ProjectPin>,
49 #[serde(default)]
51 pub verify: Option<Verify>,
52}
53
54#[derive(Debug, Clone, PartialEq, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ProjectPin {
58 pub name: String,
60 pub version: String,
62 pub sha256: String,
64 pub artifact: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct Verify {
72 #[serde(default)]
75 pub tag_probes: Vec<String>,
76}
77
78impl Manifest {
79 pub fn parse(file: &str, bytes: &[u8]) -> Result<Manifest, CoreError> {
81 let manifest: Manifest = strict_parse(file, bytes)?;
82 manifest.validate(file)?;
83 Ok(manifest)
84 }
85
86 pub fn allows_type(&self, module: &str, ty: &str) -> bool {
88 self.types
89 .iter()
90 .any(|t| t.split_once('/') == Some((module, ty)))
91 }
92
93 fn validate(&self, file: &str) -> Result<(), CoreError> {
94 let mut findings = Vec::new();
95 let mut push = |message: String| {
96 findings.push(DeployFinding {
97 file: file.to_string(),
98 line: None,
99 column: None,
100 message,
101 });
102 };
103
104 for (i, t) in self.types.iter().enumerate() {
105 match t.split_once('/') {
106 Some((module, ty)) if !ty.contains('/') => {
107 for (what, half) in [("module", module), ("type", ty)] {
108 if let Some(why) = component_problem(half) {
109 push(format!("types[{i}] {t:?}: the {what} half {why}"));
110 }
111 }
112 }
113 _ => push(format!(
114 "types[{i}] {t:?}: must have the form `module/type`"
115 )),
116 }
117 if self.types[..i].contains(t) {
118 push(format!("types[{i}] {t:?}: duplicate type"));
119 }
120 }
121 for (i, p) in self.tag_providers.iter().enumerate() {
122 if let Some(why) = component_problem(p) {
123 push(format!("tag_providers[{i}] {p:?}: {why}"));
124 }
125 if self.tag_providers[..i].contains(p) {
126 push(format!("tag_providers[{i}] {p:?}: duplicate tag provider"));
127 }
128 }
129 for problem in list_problems("exclude_tag_providers", &self.exclude_tag_providers)
130 .into_iter()
131 .chain(list_problems(
132 "read_only_tag_providers",
133 &self.read_only_tag_providers,
134 ))
135 {
136 push(problem);
137 }
138 for (i, pin) in self.projects.iter().enumerate() {
139 if pin.name.is_empty() {
140 push(format!("projects[{i}].name: must not be empty"));
141 }
142 let hex = pin.sha256.len() == 64
143 && pin
144 .sha256
145 .bytes()
146 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
147 if !hex {
148 push(format!(
149 "projects[{i}].sha256: must be 64 lowercase hex characters"
150 ));
151 }
152 }
153
154 if findings.is_empty() {
155 Ok(())
156 } else {
157 Err(CoreError::deploy_findings(findings))
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
167
168 fn findings(e: CoreError) -> Vec<DeployFinding> {
169 match e {
170 CoreError::DeployTreeInvalid(d) => d.findings,
171 other => panic!("expected deploy_tree_invalid, got {other}"),
172 }
173 }
174
175 #[test]
176 fn full_manifest_parses() {
177 let yaml = format!(
178 "schema_version: 1\nstandards_version: flux-2026\ntypes:\n - ignition/database-connection\n - ignition/tag-provider\ntag_providers: [default, edge]\nprojects:\n - name: scada\n version: 1.2.3\n sha256: {SHA}\n artifact: https://example.invalid/scada.zip\nverify:\n tag_probes:\n - \"[default]Plant/Heartbeat\"\n"
179 );
180 let m = Manifest::parse("manifest.yaml", yaml.as_bytes()).expect("parses");
181 assert_eq!(m.standards_version.as_deref(), Some("flux-2026"));
182 assert_eq!(m.types.len(), 2);
183 assert_eq!(m.tag_providers, ["default", "edge"]);
184 assert_eq!(m.projects[0].name, "scada");
185 assert_eq!(m.projects[0].sha256, SHA);
186 assert_eq!(
187 m.verify.expect("verify").tag_probes,
188 ["[default]Plant/Heartbeat"]
189 );
190 }
191
192 #[test]
193 fn minimal_manifest_parses() {
194 let m = Manifest::parse(
195 "manifest.yaml",
196 b"schema_version: 1\ntypes: [ignition/database-connection]\n",
197 )
198 .expect("parses");
199 assert!(m.tag_providers.is_empty());
200 assert!(m.projects.is_empty());
201 assert_eq!(m.verify, None);
202 assert!(m.allows_type("ignition", "database-connection"));
203 assert!(!m.allows_type("ignition", "tag-provider"));
204 assert!(!m.allows_type("ignition", "database"));
205
206 let m = Manifest::parse(
207 "manifest.yaml",
208 b"schema_version: 1\ntypes: []\nverify: {}\n",
209 )
210 .expect("empty verify");
211 assert_eq!(m.verify.expect("verify").tag_probes, Vec::<String>::new());
212 }
213
214 #[test]
215 fn manifest_semantic_findings_collected() {
216 let yaml = "schema_version: 1\ntypes:\n - nosep\n - ignition/a\n - ignition/a\ntag_providers: [dup, dup, \"a/b\"]\nprojects:\n - name: p\n version: '1'\n sha256: ABC\n artifact: x\n";
217 let found =
218 findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("invalid"));
219 let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
220 let has = |needle: &str| all.iter().any(|m| m.contains(needle));
221 assert!(has("types[0]") && has("module/type"), "{all:?}");
222 assert!(has("duplicate type"), "{all:?}");
223 assert!(has("duplicate tag provider"), "{all:?}");
224 assert!(has("tag_providers[2]") && has("path separator"), "{all:?}");
225 assert!(has("projects[0].sha256"), "{all:?}");
226 for f in &found {
227 assert_eq!(f.file, "manifest.yaml");
228 assert_eq!((f.line, f.column), (None, None));
229 }
230 }
231
232 #[test]
233 fn manifest_unknown_field_is_positioned() {
234 let e = Manifest::parse(
235 "manifest.yaml",
236 b"schema_version: 1\ntypes: []\nverify:\n extra: 1\n",
237 )
238 .expect_err("unknown nested field");
239 let f = findings(e).remove(0);
240 assert_eq!((f.line, f.column), (Some(4), Some(3)));
241 }
242
243 #[test]
244 fn exclude_tag_providers_in_manifest_is_optional_and_parses() {
245 let m =
246 Manifest::parse("manifest.yaml", b"schema_version: 1\ntypes: []\n").expect("absent");
247 assert!(m.exclude_tag_providers.is_empty());
248 let m = Manifest::parse(
249 "manifest.yaml",
250 b"schema_version: 1\ntypes: []\nexclude_tag_providers: [\"MQTT Engine\"]\n",
251 )
252 .expect("listed");
253 assert_eq!(m.exclude_tag_providers, ["MQTT Engine"]);
254 }
255
256 #[test]
257 fn exclude_tag_providers_in_manifest_bad_entries_are_refused() {
258 for (list, index, why) in [
259 ("[\"\"]", 0, "empty"),
260 ("[\" \"]", 0, "blank"),
261 ("[\" MQTT Engine\"]", 0, "whitespace"),
262 ("[\"a/b\"]", 0, "separator"),
263 ("[\".x\"]", 0, "`.`"),
264 ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
265 ] {
266 let yaml = format!("schema_version: 1\ntypes: []\nexclude_tag_providers: {list}\n");
267 let found =
268 findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("refused"));
269 assert_eq!(found.len(), 1, "{list}: {found:?}");
270 let message = &found[0].message;
271 assert!(
272 message.starts_with(&format!("exclude_tag_providers[{index}] "))
273 && message.contains(why),
274 "{list}: {message}"
275 );
276 }
277 let e = Manifest::parse(
278 "manifest.yaml",
279 b"schema_version: 1\ntypes: []\nexclude_tag_provider: [x]\n",
280 )
281 .expect_err("misspelled key");
282 let f = findings(e).remove(0);
283 assert_eq!(f.line, Some(3));
284 }
285
286 #[test]
289 fn read_only_tag_providers_in_manifest_is_optional_and_validated() {
290 let m =
291 Manifest::parse("manifest.yaml", b"schema_version: 1\ntypes: []\n").expect("absent");
292 assert!(m.read_only_tag_providers.is_empty());
293 let m = Manifest::parse(
294 "manifest.yaml",
295 b"schema_version: 1\ntypes: []\nread_only_tag_providers: [\"Broker Transmission\"]\n",
296 )
297 .expect("listed");
298 assert_eq!(m.read_only_tag_providers, ["Broker Transmission"]);
299 for (list, index, why) in [
300 ("[\"\"]", 0, "empty"),
301 ("[\" EdgeGateway01\"]", 0, "whitespace"),
302 ("[\"a/b\"]", 0, "separator"),
303 ("[EdgeGateway01, edgegateway01]", 1, "duplicate"),
304 ] {
305 let yaml = format!("schema_version: 1\ntypes: []\nread_only_tag_providers: {list}\n");
306 let found =
307 findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("refused"));
308 assert_eq!(found.len(), 1, "{list}: {found:?}");
309 let message = &found[0].message;
310 assert!(
311 message.starts_with(&format!("read_only_tag_providers[{index}] "))
312 && message.contains(why),
313 "{list}: {message}"
314 );
315 }
316 }
317}