1use serde::de::{MapAccess, Visitor};
7use serde::{Deserialize, Deserializer, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10use std::fmt;
11use std::marker::PhantomData;
12use std::path::{Component, Path};
13use thiserror::Error;
14
15pub mod codes {
17 pub const UNSUPPORTED_SCHEMA: &str = "PROJ-M002";
18 pub const BUILD_ID_MISMATCH: &str = "PROJ-M005";
19 pub const INVALID_JSON: &str = "PROJ-M008";
20 pub const INVALID_FIELD: &str = "PROJ-M009";
21 pub const TOO_MANY_COMPONENTS: &str = "PROJ-S002";
22 pub const PATH_TRAVERSAL: &str = "PROJ-S003";
23 pub const INVALID_HASH_FORMAT: &str = "PROJ-S004";
24}
25
26pub const SCHEMA_ID: &str = "webui.state-projection/v1";
28pub const PRODUCER_NAME: &str = "@microsoft/webui/projection.js";
30pub const MAX_COMPONENT_COUNT: usize = 65_535;
32pub const MAX_ROOT_PARENTS: usize = 32;
34
35#[derive(Debug, Error)]
37#[error("{message}")]
38pub struct ProjectionManifestError {
39 code: &'static str,
40 message: String,
41}
42
43impl ProjectionManifestError {
44 #[must_use]
45 pub const fn code(&self) -> &'static str {
46 self.code
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct ProjectionManifest {
54 pub schema: String,
56 pub producer: ProjectionProducer,
58 pub adapter: ProjectionAdapter,
60 pub root: String,
62 pub analysis_hash: String,
64 pub build_id: String,
66 #[serde(deserialize_with = "deserialize_unique_map")]
68 pub outputs: BTreeMap<String, String>,
69 #[serde(deserialize_with = "deserialize_unique_map")]
71 pub inputs: BTreeMap<String, String>,
72 #[serde(deserialize_with = "deserialize_unique_map")]
74 pub components: BTreeMap<String, ProjectionComponent>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct ProjectionProducer {
81 pub name: String,
83 pub version: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct ProjectionAdapter {
91 pub name: String,
93 pub bundler: String,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase", deny_unknown_fields)]
100pub struct ProjectionComponent {
101 pub module: String,
103 pub outputs: Vec<String>,
105 pub hydration_keys: Vec<String>,
107 pub navigation_keys: Vec<String>,
109}
110
111impl ProjectionManifest {
112 pub fn from_slice(bytes: &[u8]) -> Result<Self, ProjectionManifestError> {
114 let manifest: Self = serde_json::from_slice(bytes).map_err(parse_error)?;
115 manifest.validate()?;
116 Ok(manifest)
117 }
118
119 pub fn validate(&self) -> Result<(), ProjectionManifestError> {
121 if self.schema != SCHEMA_ID {
122 return Err(error(
123 codes::UNSUPPORTED_SCHEMA,
124 format!("unsupported projection schema '{}'", self.schema),
125 ));
126 }
127 if self.producer.name != PRODUCER_NAME || self.producer.version.is_empty() {
128 return Err(invalid_field(
129 "producer must identify @microsoft/webui/projection.js with a non-empty version",
130 ));
131 }
132 if self.adapter.name.is_empty() || self.adapter.bundler.is_empty() {
133 return Err(invalid_field(
134 "adapter.name and adapter.bundler must be non-empty",
135 ));
136 }
137 if !is_canonical_root(&self.root) {
138 return Err(error(
139 codes::PATH_TRAVERSAL,
140 "root must be '.' or at most 32 parent-only segments",
141 ));
142 }
143 if !is_sha256(&self.analysis_hash) || !is_sha256(&self.build_id) {
144 return Err(error(
145 codes::INVALID_HASH_FORMAT,
146 "analysisHash and buildId must be lowercase SHA-256 values",
147 ));
148 }
149 if self.components.len() > MAX_COMPONENT_COUNT {
150 return Err(error(
151 codes::TOO_MANY_COMPONENTS,
152 format!(
153 "manifest declares {} components, exceeding {}",
154 self.components.len(),
155 MAX_COMPONENT_COUNT
156 ),
157 ));
158 }
159 validate_hash_map(&self.inputs)?;
160 validate_hash_map(&self.outputs)?;
161 validate_components(self)?;
162 if self.compute_build_id() != self.build_id {
163 return Err(error(
164 codes::BUILD_ID_MISMATCH,
165 "buildId does not match the canonical manifest contents",
166 ));
167 }
168 Ok(())
169 }
170
171 #[must_use]
173 pub fn compute_build_id(&self) -> String {
174 let mut canonical = String::with_capacity(512);
175 append_record(&mut canonical, "schema", &[SCHEMA_ID]);
176 append_record(
177 &mut canonical,
178 "producer",
179 &[&self.producer.name, &self.producer.version],
180 );
181 append_record(
182 &mut canonical,
183 "adapter",
184 &[&self.adapter.name, &self.adapter.bundler],
185 );
186 append_record(&mut canonical, "root", &[&self.root]);
187 append_record(&mut canonical, "analysis", &[&self.analysis_hash]);
188
189 let input_count = self.inputs.len().to_string();
190 append_record(&mut canonical, "inputs", &[&input_count]);
191 for (path, hash) in &self.inputs {
192 append_record(&mut canonical, "input", &[path, hash]);
193 }
194
195 let output_count = self.outputs.len().to_string();
196 append_record(&mut canonical, "outputs", &[&output_count]);
197 for (path, hash) in &self.outputs {
198 append_record(&mut canonical, "output", &[path, hash]);
199 }
200
201 let component_count = self.components.len().to_string();
202 append_record(&mut canonical, "components", &[&component_count]);
203 for (tag, component) in &self.components {
204 let output_count = component.outputs.len().to_string();
205 let hydration_count = component.hydration_keys.len().to_string();
206 let navigation_count = component.navigation_keys.len().to_string();
207 let mut fields = Vec::with_capacity(
208 5 + component.outputs.len()
209 + component.hydration_keys.len()
210 + component.navigation_keys.len(),
211 );
212 fields.push(tag.as_str());
213 fields.push(component.module.as_str());
214 fields.push(output_count.as_str());
215 fields.extend(component.outputs.iter().map(String::as_str));
216 fields.push(hydration_count.as_str());
217 fields.extend(component.hydration_keys.iter().map(String::as_str));
218 fields.push(navigation_count.as_str());
219 fields.extend(component.navigation_keys.iter().map(String::as_str));
220 append_record(&mut canonical, "component", &fields);
221 }
222 hash_bytes(canonical.as_bytes())
223 }
224}
225
226fn validate_hash_map(values: &BTreeMap<String, String>) -> Result<(), ProjectionManifestError> {
227 for (path, hash) in values {
228 if is_virtual_key(path) {
229 if !is_canonical_virtual_key(path) || hash != "virtual" {
230 return Err(error(
231 codes::INVALID_HASH_FORMAT,
232 format!("invalid virtual/hash pairing for '{path}'"),
233 ));
234 }
235 } else {
236 if !is_canonical_file_key(path) {
237 return Err(error(
238 codes::PATH_TRAVERSAL,
239 format!("unsafe or non-canonical manifest path '{path}'"),
240 ));
241 }
242 if !is_sha256(hash) {
243 return Err(error(
244 codes::INVALID_HASH_FORMAT,
245 format!("invalid physical-file hash for '{path}'"),
246 ));
247 }
248 }
249 }
250 Ok(())
251}
252
253fn validate_components(manifest: &ProjectionManifest) -> Result<(), ProjectionManifestError> {
254 for (tag, component) in &manifest.components {
255 if !is_component_tag(tag) {
256 return Err(invalid_field(&format!(
257 "component tag '{tag}' is not lowercase custom-element syntax"
258 )));
259 }
260 if !manifest.inputs.contains_key(&component.module) {
261 return Err(invalid_field(&format!(
262 "component <{tag}> module '{}' is absent from inputs",
263 component.module
264 )));
265 }
266 if component.outputs.is_empty() || !is_sorted_unique(&component.outputs) {
267 return Err(invalid_field(&format!(
268 "component <{tag}> outputs must be non-empty, sorted, and unique"
269 )));
270 }
271 if component
272 .outputs
273 .iter()
274 .any(|output| !manifest.outputs.contains_key(output))
275 {
276 return Err(invalid_field(&format!(
277 "component <{tag}> references an undeclared output"
278 )));
279 }
280 if !valid_keys(&component.hydration_keys)
281 || !valid_keys(&component.navigation_keys)
282 || component
283 .hydration_keys
284 .iter()
285 .any(|key| component.navigation_keys.binary_search(key).is_err())
286 {
287 return Err(invalid_field(&format!(
288 "component <{tag}> key surfaces are invalid or navigation is not a hydration superset"
289 )));
290 }
291 }
292 Ok(())
293}
294
295fn valid_keys(values: &[String]) -> bool {
296 is_sorted_unique(values)
297 && values
298 .iter()
299 .all(|value| !value.is_empty() && !has_control(value))
300}
301
302fn deserialize_unique_map<'de, D, T>(deserializer: D) -> Result<BTreeMap<String, T>, D::Error>
303where
304 D: Deserializer<'de>,
305 T: Deserialize<'de>,
306{
307 deserializer.deserialize_map(UniqueMapVisitor(PhantomData))
308}
309
310struct UniqueMapVisitor<T>(PhantomData<T>);
311
312impl<'de, T> Visitor<'de> for UniqueMapVisitor<T>
313where
314 T: Deserialize<'de>,
315{
316 type Value = BTreeMap<String, T>;
317
318 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
319 formatter.write_str("an object with unique string keys")
320 }
321
322 fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
323 where
324 A: MapAccess<'de>,
325 {
326 let mut values = BTreeMap::new();
327 while let Some((key, value)) = access.next_entry::<String, T>()? {
328 if values.insert(key.clone(), value).is_some() {
329 return Err(serde::de::Error::custom(format!(
330 "duplicate object key '{key}'"
331 )));
332 }
333 }
334 Ok(values)
335 }
336}
337
338fn parse_error(source: serde_json::Error) -> ProjectionManifestError {
339 let code = if source.classify() == serde_json::error::Category::Syntax
340 || source.classify() == serde_json::error::Category::Eof
341 {
342 codes::INVALID_JSON
343 } else {
344 codes::INVALID_FIELD
345 };
346 error(
347 code,
348 format!("projection manifest could not be parsed: {source}"),
349 )
350}
351
352fn invalid_field(message: &str) -> ProjectionManifestError {
353 error(codes::INVALID_FIELD, message)
354}
355
356fn error(code: &'static str, message: impl Into<String>) -> ProjectionManifestError {
357 ProjectionManifestError {
358 code,
359 message: message.into(),
360 }
361}
362
363fn append_record(output: &mut String, label: &str, fields: &[&str]) {
364 output.push_str(label);
365 for field in fields {
366 output.push_str(&field.len().to_string());
367 output.push(':');
368 output.push_str(field);
369 }
370 output.push('\n');
371}
372
373fn hash_bytes(bytes: &[u8]) -> String {
374 const HEX: &[u8; 16] = b"0123456789abcdef";
375 let digest = Sha256::digest(bytes);
376 let mut output = String::with_capacity(71);
377 output.push_str("sha256:");
378 for byte in digest {
379 output.push(char::from(HEX[usize::from(byte >> 4)]));
380 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
381 }
382 output
383}
384
385#[must_use]
387pub fn is_sha256(value: &str) -> bool {
388 let Some(hex) = value.strip_prefix("sha256:") else {
389 return false;
390 };
391 hex.len() == 64 && hex.bytes().all(is_lower_hex)
392}
393
394fn is_lower_hex(byte: u8) -> bool {
395 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
396}
397
398#[must_use]
400pub fn is_virtual_key(value: &str) -> bool {
401 value.starts_with("virtual:")
402}
403
404fn is_canonical_virtual_key(value: &str) -> bool {
405 let Some(hex) = value.strip_prefix("virtual:") else {
406 return false;
407 };
408 !hex.is_empty() && hex.len().is_multiple_of(2) && hex.bytes().all(is_lower_hex)
409}
410
411#[must_use]
413pub fn is_canonical_file_key(value: &str) -> bool {
414 if value.is_empty()
415 || value.starts_with('/')
416 || value.starts_with("./")
417 || value.contains('\\')
418 || has_control(value)
419 {
420 return false;
421 }
422 let mut saw_segment = false;
423 for component in Path::new(value).components() {
424 match component {
425 Component::Normal(segment) if !segment.is_empty() => saw_segment = true,
426 _ => return false,
427 }
428 }
429 saw_segment && value.split('/').all(|segment| !segment.is_empty())
430}
431
432fn is_canonical_root(value: &str) -> bool {
433 if value == "." {
434 return true;
435 }
436 let segments: Vec<&str> = value.split('/').collect();
437 !segments.is_empty()
438 && segments.len() <= MAX_ROOT_PARENTS
439 && segments.iter().all(|segment| *segment == "..")
440}
441
442fn has_control(value: &str) -> bool {
443 value.chars().any(char::is_control)
444}
445
446fn is_component_tag(value: &str) -> bool {
447 let bytes = value.as_bytes();
448 if bytes.len() < 3 || !bytes.contains(&b'-') || !bytes[0].is_ascii_lowercase() {
449 return false;
450 }
451 bytes.iter().all(|byte| {
452 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'-' | b'.' | b'_')
453 })
454}
455
456fn is_sorted_unique(values: &[String]) -> bool {
457 values
458 .windows(2)
459 .all(|pair| pair[0].as_bytes() < pair[1].as_bytes())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn cross_language_build_id_matches() {
468 let mut manifest = ProjectionManifest {
469 schema: SCHEMA_ID.to_string(),
470 producer: ProjectionProducer {
471 name: PRODUCER_NAME.to_string(),
472 version: "0.0.18".to_string(),
473 },
474 adapter: ProjectionAdapter {
475 name: "esbuild".to_string(),
476 bundler: "esbuild@0.28.1".to_string(),
477 },
478 root: "..".to_string(),
479 analysis_hash: format!("sha256:{}", "1".repeat(64)),
480 build_id: String::new(),
481 outputs: BTreeMap::from([(
482 "dist/a.js".to_string(),
483 format!("sha256:{}", "3".repeat(64)),
484 )]),
485 inputs: BTreeMap::from([(
486 "src/a.ts".to_string(),
487 format!("sha256:{}", "2".repeat(64)),
488 )]),
489 components: BTreeMap::from([(
490 "a-card".to_string(),
491 ProjectionComponent {
492 module: "src/a.ts".to_string(),
493 outputs: vec!["dist/a.js".to_string()],
494 hydration_keys: vec!["displayValue".to_string()],
495 navigation_keys: vec!["displayValue".to_string(), "é".to_string()],
496 },
497 )]),
498 };
499 manifest.build_id = manifest.compute_build_id();
500 assert_eq!(
501 manifest.build_id,
502 "sha256:8319202a060626c39cce76df50197c92dee27aab29d601161183c188204d7c18"
503 );
504 assert!(manifest.validate().is_ok());
505 }
506
507 #[test]
508 fn duplicate_json_key_is_rejected() {
509 let error = ProjectionManifest::from_slice(
510 br#"{"schema":"webui.state-projection/v1","schema":"duplicate"}"#,
511 )
512 .unwrap_err();
513 assert_eq!(error.code(), codes::INVALID_FIELD);
514 }
515}