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