Skip to main content

webui_protocol/
projection_manifest.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Shared state-projection manifest schema and structural validation.
5
6use 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
15/// Stable manifest diagnostic codes shared by all hosts.
16pub 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
26/// Current projection manifest schema identifier.
27pub const SCHEMA_ID: &str = "webui.state-projection/v1";
28/// Required producer identity.
29pub const PRODUCER_NAME: &str = "@microsoft/webui/projection.js";
30/// Maximum component entries in one fragment.
31pub const MAX_COMPONENT_COUNT: usize = 65_535;
32/// Maximum parent segments in `root`.
33pub const MAX_ROOT_PARENTS: usize = 32;
34
35/// A structurally invalid projection manifest.
36#[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/// Versioned projection manifest fragment.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct ProjectionManifest {
54    /// Versioned schema identifier.
55    pub schema: String,
56    /// Producer package identity.
57    pub producer: ProjectionProducer,
58    /// Adapter and bundler identity.
59    pub adapter: ProjectionAdapter,
60    /// Build root relative to the manifest directory.
61    pub root: String,
62    /// Hash of normalized graph edges and output membership.
63    pub analysis_hash: String,
64    /// Deterministic hash of the complete manifest proof.
65    pub build_id: String,
66    /// Emitted output path/virtual ID to content hash.
67    #[serde(deserialize_with = "deserialize_unique_map")]
68    pub outputs: BTreeMap<String, String>,
69    /// Input path/virtual ID to content hash.
70    #[serde(deserialize_with = "deserialize_unique_map")]
71    pub inputs: BTreeMap<String, String>,
72    /// Exact component surfaces keyed by custom-element tag.
73    #[serde(deserialize_with = "deserialize_unique_map")]
74    pub components: BTreeMap<String, ProjectionComponent>,
75}
76
77/// Manifest producer identity.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct ProjectionProducer {
81    /// Producer package name.
82    pub name: String,
83    /// Producer package version.
84    pub version: String,
85}
86
87/// Bundler adapter identity.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct ProjectionAdapter {
91    /// Adapter name.
92    pub name: String,
93    /// Bundler name and version.
94    pub bundler: String,
95}
96
97/// One shipped component's exact state surfaces.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase", deny_unknown_fields)]
100pub struct ProjectionComponent {
101    /// Defining input module key.
102    pub module: String,
103    /// Output keys containing the component.
104    pub outputs: Vec<String>,
105    /// Exact initial `@observable` keys.
106    pub hydration_keys: Vec<String>,
107    /// Exact navigation `@observable + @attr` keys.
108    pub navigation_keys: Vec<String>,
109}
110
111impl ProjectionManifest {
112    /// Parse and structurally validate one JSON manifest.
113    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    /// Validate schema, hashes, references, ordering, and deterministic ID.
120    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    /// Recompute the deterministic cross-language build ID.
172    #[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/// Whether a value is exactly `sha256:` plus 64 lowercase hex characters.
386#[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/// Whether a manifest key identifies a virtual module/output.
399#[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/// Whether a physical manifest key is safe and canonical.
412#[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}