use serde::de::{MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;
use std::marker::PhantomData;
use std::path::{Component, Path};
use thiserror::Error;
pub mod codes {
pub const UNSUPPORTED_SCHEMA: &str = "PROJ-M002";
pub const BUILD_ID_MISMATCH: &str = "PROJ-M005";
pub const INVALID_JSON: &str = "PROJ-M008";
pub const INVALID_FIELD: &str = "PROJ-M009";
pub const TOO_MANY_COMPONENTS: &str = "PROJ-S002";
pub const PATH_TRAVERSAL: &str = "PROJ-S003";
pub const INVALID_HASH_FORMAT: &str = "PROJ-S004";
}
pub const SCHEMA_ID: &str = "webui.state-projection/v1";
pub const PRODUCER_NAME: &str = "@microsoft/webui/projection.js";
pub const MAX_COMPONENT_COUNT: usize = 65_535;
pub const MAX_ROOT_PARENTS: usize = 32;
#[derive(Debug, Error)]
#[error("{message}")]
pub struct ProjectionManifestError {
code: &'static str,
message: String,
}
impl ProjectionManifestError {
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProjectionManifest {
pub schema: String,
pub producer: ProjectionProducer,
pub adapter: ProjectionAdapter,
pub root: String,
pub analysis_hash: String,
pub build_id: String,
#[serde(deserialize_with = "deserialize_unique_map")]
pub outputs: BTreeMap<String, String>,
#[serde(deserialize_with = "deserialize_unique_map")]
pub inputs: BTreeMap<String, String>,
#[serde(deserialize_with = "deserialize_unique_map")]
pub components: BTreeMap<String, ProjectionComponent>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(deserialize_with = "deserialize_unique_map")]
pub entry_closures: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectionProducer {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectionAdapter {
pub name: String,
pub bundler: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProjectionComponent {
pub module: String,
pub outputs: Vec<String>,
pub hydration_keys: Vec<String>,
pub navigation_keys: Vec<String>,
}
impl ProjectionManifest {
pub fn from_slice(bytes: &[u8]) -> Result<Self, ProjectionManifestError> {
let manifest: Self = serde_json::from_slice(bytes).map_err(parse_error)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), ProjectionManifestError> {
if self.schema != SCHEMA_ID {
return Err(error(
codes::UNSUPPORTED_SCHEMA,
format!("unsupported projection schema '{}'", self.schema),
));
}
if self.producer.name != PRODUCER_NAME || self.producer.version.is_empty() {
return Err(invalid_field(
"producer must identify @microsoft/webui/projection.js with a non-empty version",
));
}
if self.adapter.name.is_empty() || self.adapter.bundler.is_empty() {
return Err(invalid_field(
"adapter.name and adapter.bundler must be non-empty",
));
}
if !is_canonical_root(&self.root) {
return Err(error(
codes::PATH_TRAVERSAL,
"root must be '.' or at most 32 parent-only segments",
));
}
if !is_sha256(&self.analysis_hash) || !is_sha256(&self.build_id) {
return Err(error(
codes::INVALID_HASH_FORMAT,
"analysisHash and buildId must be lowercase SHA-256 values",
));
}
if self.components.len() > MAX_COMPONENT_COUNT {
return Err(error(
codes::TOO_MANY_COMPONENTS,
format!(
"manifest declares {} components, exceeding {}",
self.components.len(),
MAX_COMPONENT_COUNT
),
));
}
validate_hash_map(&self.inputs)?;
validate_hash_map(&self.outputs)?;
validate_components(self)?;
validate_entry_closures(self)?;
if self.compute_build_id() != self.build_id {
return Err(error(
codes::BUILD_ID_MISMATCH,
"buildId does not match the canonical manifest contents",
));
}
Ok(())
}
#[must_use]
pub fn compute_build_id(&self) -> String {
let mut canonical = String::with_capacity(512);
append_record(&mut canonical, "schema", &[SCHEMA_ID]);
append_record(
&mut canonical,
"producer",
&[&self.producer.name, &self.producer.version],
);
append_record(
&mut canonical,
"adapter",
&[&self.adapter.name, &self.adapter.bundler],
);
append_record(&mut canonical, "root", &[&self.root]);
append_record(&mut canonical, "analysis", &[&self.analysis_hash]);
let input_count = self.inputs.len().to_string();
append_record(&mut canonical, "inputs", &[&input_count]);
for (path, hash) in &self.inputs {
append_record(&mut canonical, "input", &[path, hash]);
}
let output_count = self.outputs.len().to_string();
append_record(&mut canonical, "outputs", &[&output_count]);
for (path, hash) in &self.outputs {
append_record(&mut canonical, "output", &[path, hash]);
}
let component_count = self.components.len().to_string();
append_record(&mut canonical, "components", &[&component_count]);
for (tag, component) in &self.components {
let output_count = component.outputs.len().to_string();
let hydration_count = component.hydration_keys.len().to_string();
let navigation_count = component.navigation_keys.len().to_string();
let mut fields = Vec::with_capacity(
5 + component.outputs.len()
+ component.hydration_keys.len()
+ component.navigation_keys.len(),
);
fields.push(tag.as_str());
fields.push(component.module.as_str());
fields.push(output_count.as_str());
fields.extend(component.outputs.iter().map(String::as_str));
fields.push(hydration_count.as_str());
fields.extend(component.hydration_keys.iter().map(String::as_str));
fields.push(navigation_count.as_str());
fields.extend(component.navigation_keys.iter().map(String::as_str));
append_record(&mut canonical, "component", &fields);
}
if !self.entry_closures.is_empty() {
let closure_count = self.entry_closures.len().to_string();
append_record(&mut canonical, "entryClosures", &[&closure_count]);
for (entry, closure) in &self.entry_closures {
let member_count = closure.len().to_string();
let mut fields = Vec::with_capacity(2 + closure.len());
fields.push(entry.as_str());
fields.push(member_count.as_str());
fields.extend(closure.iter().map(String::as_str));
append_record(&mut canonical, "entryClosure", &fields);
}
}
hash_bytes(canonical.as_bytes())
}
}
fn validate_entry_closures(manifest: &ProjectionManifest) -> Result<(), ProjectionManifestError> {
for (entry, closure) in &manifest.entry_closures {
if !manifest.outputs.contains_key(entry) {
return Err(invalid_field(&format!(
"entry closure key '{entry}' is absent from outputs"
)));
}
for member in closure {
if member == entry {
return Err(invalid_field(&format!(
"entry closure '{entry}' includes itself"
)));
}
if !manifest.outputs.contains_key(member) {
return Err(invalid_field(&format!(
"entry closure '{entry}' references undeclared output '{member}'"
)));
}
}
if has_duplicate(closure) {
return Err(invalid_field(&format!(
"entry closure '{entry}' repeats an output"
)));
}
}
Ok(())
}
fn has_duplicate(values: &[String]) -> bool {
for (index, value) in values.iter().enumerate() {
if values[index + 1..].contains(value) {
return true;
}
}
false
}
fn validate_hash_map(values: &BTreeMap<String, String>) -> Result<(), ProjectionManifestError> {
for (path, hash) in values {
if is_virtual_key(path) {
if !is_canonical_virtual_key(path) || hash != "virtual" {
return Err(error(
codes::INVALID_HASH_FORMAT,
format!("invalid virtual/hash pairing for '{path}'"),
));
}
} else {
if !is_canonical_file_key(path) {
return Err(error(
codes::PATH_TRAVERSAL,
format!("unsafe or non-canonical manifest path '{path}'"),
));
}
if !is_sha256(hash) {
return Err(error(
codes::INVALID_HASH_FORMAT,
format!("invalid physical-file hash for '{path}'"),
));
}
}
}
Ok(())
}
fn validate_components(manifest: &ProjectionManifest) -> Result<(), ProjectionManifestError> {
for (tag, component) in &manifest.components {
if !is_component_tag(tag) {
return Err(invalid_field(&format!(
"component tag '{tag}' is not lowercase custom-element syntax"
)));
}
if !manifest.inputs.contains_key(&component.module) {
return Err(invalid_field(&format!(
"component <{tag}> module '{}' is absent from inputs",
component.module
)));
}
if component.outputs.is_empty() || !is_sorted_unique(&component.outputs) {
return Err(invalid_field(&format!(
"component <{tag}> outputs must be non-empty, sorted, and unique"
)));
}
if component
.outputs
.iter()
.any(|output| !manifest.outputs.contains_key(output))
{
return Err(invalid_field(&format!(
"component <{tag}> references an undeclared output"
)));
}
if !valid_keys(&component.hydration_keys)
|| !valid_keys(&component.navigation_keys)
|| component
.hydration_keys
.iter()
.any(|key| component.navigation_keys.binary_search(key).is_err())
{
return Err(invalid_field(&format!(
"component <{tag}> key surfaces are invalid or navigation is not a hydration superset"
)));
}
}
Ok(())
}
fn valid_keys(values: &[String]) -> bool {
is_sorted_unique(values)
&& values
.iter()
.all(|value| !value.is_empty() && !has_control(value))
}
fn deserialize_unique_map<'de, D, T>(deserializer: D) -> Result<BTreeMap<String, T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
deserializer.deserialize_map(UniqueMapVisitor(PhantomData))
}
struct UniqueMapVisitor<T>(PhantomData<T>);
impl<'de, T> Visitor<'de> for UniqueMapVisitor<T>
where
T: Deserialize<'de>,
{
type Value = BTreeMap<String, T>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("an object with unique string keys")
}
fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut values = BTreeMap::new();
while let Some((key, value)) = access.next_entry::<String, T>()? {
if values.insert(key.clone(), value).is_some() {
return Err(serde::de::Error::custom(format!(
"duplicate object key '{key}'"
)));
}
}
Ok(values)
}
}
fn parse_error(source: serde_json::Error) -> ProjectionManifestError {
let code = if source.classify() == serde_json::error::Category::Syntax
|| source.classify() == serde_json::error::Category::Eof
{
codes::INVALID_JSON
} else {
codes::INVALID_FIELD
};
error(
code,
format!("projection manifest could not be parsed: {source}"),
)
}
fn invalid_field(message: &str) -> ProjectionManifestError {
error(codes::INVALID_FIELD, message)
}
fn error(code: &'static str, message: impl Into<String>) -> ProjectionManifestError {
ProjectionManifestError {
code,
message: message.into(),
}
}
fn append_record(output: &mut String, label: &str, fields: &[&str]) {
output.push_str(label);
for field in fields {
output.push_str(&field.len().to_string());
output.push(':');
output.push_str(field);
}
output.push('\n');
}
fn hash_bytes(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(71);
output.push_str("sha256:");
for byte in digest {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
#[must_use]
pub fn is_sha256(value: &str) -> bool {
let Some(hex) = value.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64 && hex.bytes().all(is_lower_hex)
}
fn is_lower_hex(byte: u8) -> bool {
byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
}
#[must_use]
pub fn is_virtual_key(value: &str) -> bool {
value.starts_with("virtual:")
}
fn is_canonical_virtual_key(value: &str) -> bool {
let Some(hex) = value.strip_prefix("virtual:") else {
return false;
};
!hex.is_empty() && hex.len().is_multiple_of(2) && hex.bytes().all(is_lower_hex)
}
#[must_use]
pub fn is_canonical_file_key(value: &str) -> bool {
if value.is_empty()
|| value.starts_with('/')
|| value.starts_with("./")
|| value.contains('\\')
|| has_control(value)
{
return false;
}
let mut saw_segment = false;
for component in Path::new(value).components() {
match component {
Component::Normal(segment) if !segment.is_empty() => saw_segment = true,
_ => return false,
}
}
saw_segment && value.split('/').all(|segment| !segment.is_empty())
}
fn is_canonical_root(value: &str) -> bool {
if value == "." {
return true;
}
let segments: Vec<&str> = value.split('/').collect();
!segments.is_empty()
&& segments.len() <= MAX_ROOT_PARENTS
&& segments.iter().all(|segment| *segment == "..")
}
fn has_control(value: &str) -> bool {
value.chars().any(char::is_control)
}
fn is_component_tag(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 3 || !bytes.contains(&b'-') || !bytes[0].is_ascii_lowercase() {
return false;
}
bytes.iter().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(*byte, b'-' | b'.' | b'_')
})
}
fn is_sorted_unique(values: &[String]) -> bool {
values
.windows(2)
.all(|pair| pair[0].as_bytes() < pair[1].as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_language_build_id_matches() {
let mut manifest = ProjectionManifest {
schema: SCHEMA_ID.to_string(),
producer: ProjectionProducer {
name: PRODUCER_NAME.to_string(),
version: "0.0.18".to_string(),
},
adapter: ProjectionAdapter {
name: "esbuild".to_string(),
bundler: "esbuild@0.28.1".to_string(),
},
root: "..".to_string(),
analysis_hash: format!("sha256:{}", "1".repeat(64)),
build_id: String::new(),
outputs: BTreeMap::from([(
"dist/a.js".to_string(),
format!("sha256:{}", "3".repeat(64)),
)]),
inputs: BTreeMap::from([(
"src/a.ts".to_string(),
format!("sha256:{}", "2".repeat(64)),
)]),
components: BTreeMap::from([(
"a-card".to_string(),
ProjectionComponent {
module: "src/a.ts".to_string(),
outputs: vec!["dist/a.js".to_string()],
hydration_keys: vec!["displayValue".to_string()],
navigation_keys: vec!["displayValue".to_string(), "é".to_string()],
},
)]),
entry_closures: BTreeMap::new(),
};
manifest.build_id = manifest.compute_build_id();
assert_eq!(
manifest.build_id,
"sha256:8319202a060626c39cce76df50197c92dee27aab29d601161183c188204d7c18"
);
assert!(manifest.validate().is_ok());
}
#[test]
fn duplicate_json_key_is_rejected() {
let error = ProjectionManifest::from_slice(
br#"{"schema":"webui.state-projection/v1","schema":"duplicate"}"#,
)
.unwrap_err();
assert_eq!(error.code(), codes::INVALID_FIELD);
}
fn manifest_with_closures(closures: BTreeMap<String, Vec<String>>) -> ProjectionManifest {
let mut manifest = ProjectionManifest {
schema: SCHEMA_ID.to_string(),
producer: ProjectionProducer {
name: PRODUCER_NAME.to_string(),
version: "0.0.19".to_string(),
},
adapter: ProjectionAdapter {
name: "esbuild".to_string(),
bundler: "esbuild@0.28.1".to_string(),
},
root: "..".to_string(),
analysis_hash: format!("sha256:{}", "1".repeat(64)),
build_id: String::new(),
outputs: BTreeMap::from([
(
"dist/index.js".to_string(),
format!("sha256:{}", "3".repeat(64)),
),
(
"dist/chunk.js".to_string(),
format!("sha256:{}", "4".repeat(64)),
),
]),
inputs: BTreeMap::from([(
"src/a.ts".to_string(),
format!("sha256:{}", "2".repeat(64)),
)]),
components: BTreeMap::new(),
entry_closures: closures,
};
manifest.build_id = manifest.compute_build_id();
manifest
}
#[test]
fn entry_closure_accepts_declared_outputs() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/chunk.js".to_string()],
)]));
assert!(manifest.validate().is_ok());
}
#[test]
fn entry_closure_rejects_undeclared_key() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/missing.js".to_string(),
vec!["dist/chunk.js".to_string()],
)]));
let error = manifest.validate().unwrap_err();
assert_eq!(error.code(), codes::INVALID_FIELD);
}
#[test]
fn entry_closure_rejects_undeclared_member() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/absent.js".to_string()],
)]));
let error = manifest.validate().unwrap_err();
assert_eq!(error.code(), codes::INVALID_FIELD);
}
#[test]
fn entry_closure_rejects_self_reference() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/index.js".to_string()],
)]));
let error = manifest.validate().unwrap_err();
assert_eq!(error.code(), codes::INVALID_FIELD);
}
#[test]
fn entry_closure_rejects_repeated_member() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/chunk.js".to_string(), "dist/chunk.js".to_string()],
)]));
let error = manifest.validate().unwrap_err();
assert_eq!(error.code(), codes::INVALID_FIELD);
}
#[test]
fn entry_closure_order_changes_build_id() {
let unordered = manifest_with_closures(BTreeMap::new());
let one = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/chunk.js".to_string()],
)]));
assert_ne!(unordered.compute_build_id(), one.compute_build_id());
let mut a = manifest_with_closures(BTreeMap::new());
a.outputs.insert(
"dist/shared.js".to_string(),
format!("sha256:{}", "5".repeat(64)),
);
let mut b = a.clone();
a.entry_closures = BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/chunk.js".to_string(), "dist/shared.js".to_string()],
)]);
b.entry_closures = BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/shared.js".to_string(), "dist/chunk.js".to_string()],
)]);
assert_ne!(a.compute_build_id(), b.compute_build_id());
a.build_id = a.compute_build_id();
b.build_id = b.compute_build_id();
assert!(a.validate().is_ok());
assert!(b.validate().is_ok());
}
#[test]
fn entry_closure_round_trips_as_camel_case() {
let manifest = manifest_with_closures(BTreeMap::from([(
"dist/index.js".to_string(),
vec!["dist/chunk.js".to_string()],
)]));
let json = serde_json::to_vec(&manifest).expect("manifest serializes");
assert!(String::from_utf8_lossy(&json).contains("\"entryClosures\""));
let parsed = ProjectionManifest::from_slice(&json).expect("manifest parses");
assert_eq!(
parsed.entry_closures.get("dist/index.js"),
Some(&vec!["dist/chunk.js".to_string()])
);
}
#[test]
fn absent_entry_closures_parse_as_empty() {
let mut manifest = manifest_with_closures(BTreeMap::new());
manifest.build_id = manifest.compute_build_id();
let json = serde_json::to_vec(&manifest).expect("manifest serializes");
assert!(!String::from_utf8_lossy(&json).contains("entryClosures"));
assert!(ProjectionManifest::from_slice(&json).is_ok());
}
}