diff --git a/Cargo.lock b/Cargo.lock index e702356..62fd160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -14,6 +23,50 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "itoa" version = "1.0.18" @@ -30,6 +83,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" @@ -60,6 +119,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -109,15 +177,30 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "stack-compiler" version = "0.1.0" -source = "git+https://github.com/stack-sh/compiler.git?rev=3d2379483da1edaeb24a26d43743587a4f5bd645#3d2379483da1edaeb24a26d43743587a4f5bd645" +source = "git+https://github.com/stack-sh/compiler.git?rev=4a18fac42afc2256a1bb3a6ff13d12d732a391e7#4a18fac42afc2256a1bb3a6ff13d12d732a391e7" [[package]] name = "stack-engine" -version = "0.3.0" +version = "0.4.0" dependencies = [ + "roxmltree", + "serde", + "serde_json", + "sha2", "stack-compiler", "stack-formatter", "stack-theme", @@ -125,12 +208,13 @@ dependencies = [ [[package]] name = "stack-engine-wasm" -version = "0.3.0" +version = "0.4.0" dependencies = [ "js-sys", "serde", "serde_json", "stack-engine", + "stack-theme", "wasm-bindgen", ] @@ -173,6 +257,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 54b677a..ddc55bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,10 @@ license = "Apache-2.0" repository = "https://github.com/stack-sh/engine" [workspace.dependencies] +roxmltree = "=0.21.1" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" -stack-compiler = { git = "https://github.com/stack-sh/compiler.git", rev = "3d2379483da1edaeb24a26d43743587a4f5bd645" } +sha2 = "=0.11.0" +stack-compiler = { git = "https://github.com/stack-sh/compiler.git", rev = "4a18fac42afc2256a1bb3a6ff13d12d732a391e7" } stack-formatter = { path = "crates/stack-formatter" } stack-theme = { git = "https://github.com/stack-sh/theme.git", rev = "5dbe41326370260cfc6b72d4aab4470318d66dab" } diff --git a/README.md b/README.md index 91d7642..50a9015 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,11 @@ cargo doc --workspace --no-deps `stack-formatter` is pure and accepts source bytes or UTF-8 text. Lexical and syntax errors return diagnostics without formatted output. Syntactically valid source remains formattable when semantic diagnostics exist. -`stack-engine` exposes byte-oriented `format`, `check`, and `render` methods through an engine bound to the embedded or a caller-provided validated catalog. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. +`stack-engine` exposes byte-oriented `format`, `check`, and `render` methods through an engine bound to the embedded or a caller-provided validated catalog. `ProviderPack::new` accepts a typed user-imported manifest and caller-owned SVG strings, verifies exact asset hashes and safe SVG structure, and computes a deterministic content revision before `Engine::with_provider_packs` can resolve namespaced IDs. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs or provider packs and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme and provider packs, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. -The renderer emits fixed-dimension standalone SVG with embedded catalog icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. The bundled catalog provides the first-party explicit icon identifiers `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability` in every core theme. Canonical SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` only when intentionally regenerating them. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. +The renderer emits fixed-dimension standalone SVG with embedded catalog or provider icons, local marker references, escaped authored text, accessible title and description metadata, and no script, event handler, external URL, host font measurement, or runtime I/O. Provider artwork preserves the authored node `kind`; each render returns the exact used-asset notices and writes provider ID, icon IDs, and pack revision into SVG metadata. The bundled catalog provides the first-party explicit icon identifiers `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability` in every core theme. Canonical SVG snapshots are byte-stable and parsed by `scripts/validate-svg.py`; set `UPDATE_STACK_SNAPSHOTS=1` only when intentionally regenerating them. CI also executes one exact numeric geometry fixture in both the native suite and a WASI build. -The npm package exports synchronous `format`, `check`, and `render` functions after asynchronous module initialization. Each operation accepts `string | Uint8Array` and returns a specific typed result with camel-case metadata and portable diagnostics. Diagnostics preserve the compiler's primary range, ordered `expected` values, corrective help, and related source locations. Invalid UTF-8 remains a normal `STK1001` result. Unsupported JavaScript input types and internal operational failures throw at the adapter boundary. Shared fixtures compare complete native and WebAssembly results, including formatted source, diagnostics, SVG, and metadata. Artifact validation audits WebAssembly imports and package contents; browser consumers retain responsibility for loading the module and performing any DOM, filesystem, network, or clock work. +The npm package exports synchronous `format`, `check`, `render`, `checkWithProviderPacks`, and `renderWithProviderPacks` functions after asynchronous module initialization. Provider-pack operations accept JSON-compatible local manifest and SVG data; they never discover a path or initiate a request. Each operation accepts `string | Uint8Array` source and returns a specific typed result with camel-case metadata and portable diagnostics. Diagnostics preserve the compiler's primary range, ordered `expected` values, corrective help, and related source locations. Invalid UTF-8 remains a normal `STK1001` result. Unsupported JavaScript input types and internal operational failures throw at the adapter boundary. Shared fixtures exercise native and WebAssembly provider resolution. Artifact validation audits WebAssembly imports and package contents; browser consumers retain responsibility for loading the module and performing any DOM, filesystem, network, or clock work. Public npm releases are produced from GitHub Releases after the repository checks pass. See [RELEASING.md](./RELEASING.md) for the first-release bootstrap and subsequent trusted-publishing flow. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 1a9bbc4..8831ff6 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,8 +4,11 @@ | Component | Revision | License | Source | Notes | | --- | --- | --- | --- | --- | -| `stack-compiler` | `3d2379483da1edaeb24a26d43743587a4f5bd645` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | -| `stack-theme` | `d25b883884420adcc124e4c9c786ad92925eae60` | Apache-2.0 | | Unmodified Rust dependency with repository-authored fallback and explicit core SVG assets and deterministic metrics; its license and notice obligations apply to distributions that include it. | +| `stack-compiler` | `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | +| `stack-theme` | `5dbe41326370260cfc6b72d4aab4470318d66dab` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses caller-owned processed provider SVG before allowlisted in-memory embedding. | +| `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Verifies provider asset hashes and computes deterministic provider-pack revisions. | +| `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | MIT OR Apache-2.0 | , | Target-specific SHA-256 acceleration support. | | `serde` / `serde_core` | `1.0.229` | MIT OR Apache-2.0 | | Runtime catalog data types through `stack-theme`. | | `serde_json` | `1.0.151` | MIT OR Apache-2.0 | | Runtime embedded-catalog decoding through `stack-theme`; also used by formatter conformance tests. | | `itoa` | `1.0.18` | MIT OR Apache-2.0 | | Transitive runtime dependency of `serde_json`. | diff --git a/crates/stack-engine-wasm/Cargo.toml b/crates/stack-engine-wasm/Cargo.toml index cd00609..4b29a64 100644 --- a/crates/stack-engine-wasm/Cargo.toml +++ b/crates/stack-engine-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine-wasm" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -12,7 +12,9 @@ crate-type = ["cdylib", "rlib"] [dependencies] serde.workspace = true +serde_json.workspace = true stack-engine = { path = "../stack-engine" } +stack-theme.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { version = "=0.3.104", default-features = false } diff --git a/crates/stack-engine-wasm/src/lib.rs b/crates/stack-engine-wasm/src/lib.rs index c4d2727..d15d8bc 100644 --- a/crates/stack-engine-wasm/src/lib.rs +++ b/crates/stack-engine-wasm/src/lib.rs @@ -8,11 +8,11 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -use serde::Serialize; -use stack_engine::{Engine, OperationResult}; +use serde::{Deserialize, Serialize}; +use stack_engine::{Engine, OperationResult, ProviderAsset, ProviderPack}; #[cfg(target_arch = "wasm32")] -use js_sys::{Array, Object, Reflect, TypeError, Uint8Array}; +use js_sys::{Array, JSON, Object, Reflect, TypeError, Uint8Array}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; @@ -48,6 +48,60 @@ pub struct RenderResult { pub diagnostics: Vec, /// Engine and input provenance. pub metadata: EngineMetadata, + /// Provider-specific notices for the exact embedded assets. + pub provider_notices: Vec, +} + +/// JavaScript-facing provider provenance for one rendered pack. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderNotice { + /// Stable provider namespace. + pub provider_id: String, + /// Human-readable provider name. + pub provider_name: String, + /// Provider-pack semantic version. + pub pack_version: String, + /// Deterministic manifest-and-assets SHA-256. + pub pack_revision: String, + /// Audited upstream release. + pub source_release: String, + /// Complete official archive SHA-256. + pub archive_sha256: String, + /// Provider terms URL. + pub terms_url: String, + /// User-visible attribution. + pub attribution: String, + /// User-visible terms summary. + pub terms_summary: String, + /// User-visible non-endorsement statement. + pub non_endorsement: String, + /// Exact provider icons embedded in the output. + pub icons: Vec, +} + +/// JavaScript-facing provider icon notice. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderNoticeIcon { + /// Namespaced provider icon identifier. + pub id: String, + /// Official provider product name. + pub product_name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderPackInput { + manifest: stack_theme::ProviderPack, + assets: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderAssetInput { + path: String, + svg: String, } /// Version metadata attached to every operation result. @@ -151,6 +205,50 @@ pub fn render_bytes(source: &[u8]) -> OperationResult { Engine::bundled().render(source).map(RenderResult::from) } +/// Checks source against caller-owned provider packs encoded as local JSON data. +pub fn check_with_provider_packs_bytes( + source: &[u8], + provider_packs_json: &str, +) -> OperationResult { + let provider_packs = parse_provider_packs(provider_packs_json)?; + Engine::with_provider_packs(&provider_packs)? + .check(source) + .map(CheckResult::from) +} + +/// Renders source against caller-owned provider packs encoded as local JSON data. +pub fn render_with_provider_packs_bytes( + source: &[u8], + provider_packs_json: &str, +) -> OperationResult { + let provider_packs = parse_provider_packs(provider_packs_json)?; + Engine::with_provider_packs(&provider_packs)? + .render(source) + .map(RenderResult::from) +} + +fn parse_provider_packs(provider_packs_json: &str) -> OperationResult> { + let inputs: Vec = + serde_json::from_str(provider_packs_json).map_err(|_| { + stack_engine::OperationalError::InvalidProviderPack { + reason: "provider pack input is not valid JSON", + } + })?; + inputs + .into_iter() + .map(|input| { + ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + ) + }) + .collect() +} + impl From for FormatResult { fn from(output: stack_engine::FormatOutput) -> Self { Self { @@ -188,6 +286,42 @@ impl From for RenderResult { .map(Diagnostic::from) .collect(), metadata: EngineMetadata::from(output.metadata), + provider_notices: output + .provider_notices + .into_iter() + .map(ProviderNotice::from) + .collect(), + } + } +} + +impl From for ProviderNotice { + fn from(notice: stack_engine::ProviderNotice) -> Self { + Self { + provider_id: notice.provider_id, + provider_name: notice.provider_name, + pack_version: notice.pack_version, + pack_revision: notice.pack_revision, + source_release: notice.source_release, + archive_sha256: notice.archive_sha256, + terms_url: notice.terms_url, + attribution: notice.attribution, + terms_summary: notice.terms_summary, + non_endorsement: notice.non_endorsement, + icons: notice + .icons + .into_iter() + .map(ProviderNoticeIcon::from) + .collect(), + } + } +} + +impl From for ProviderNoticeIcon { + fn from(icon: stack_engine::ProviderNoticeIcon) -> Self { + Self { + id: icon.id, + product_name: icon.product_name, } } } @@ -326,11 +460,43 @@ export interface RenderResult { readonly svg: string | null; readonly diagnostics: readonly Diagnostic[]; readonly metadata: EngineMetadata; + readonly providerNotices: readonly ProviderNotice[]; +} + +export interface ProviderNoticeIcon { + readonly id: string; + readonly productName: string; +} + +export interface ProviderNotice { + readonly providerId: string; + readonly providerName: string; + readonly packVersion: string; + readonly packRevision: string; + readonly sourceRelease: string; + readonly archiveSha256: string; + readonly termsUrl: string; + readonly attribution: string; + readonly termsSummary: string; + readonly nonEndorsement: string; + readonly icons: readonly ProviderNoticeIcon[]; +} + +export interface ProviderAssetInput { + readonly path: string; + readonly svg: string; +} + +export interface ProviderPackInput { + readonly manifest: Readonly>; + readonly assets: readonly ProviderAssetInput[]; } export function format(source: StackSource): FormatResult; export function check(source: StackSource): CheckResult; export function render(source: StackSource): RenderResult; +export function checkWithProviderPacks(source: StackSource, providerPacks: readonly ProviderPackInput[]): CheckResult; +export function renderWithProviderPacks(source: StackSource, providerPacks: readonly ProviderPackInput[]): RenderResult; "#; #[cfg(target_arch = "wasm32")] @@ -360,6 +526,40 @@ pub fn render_js(source: JsValue) -> Result { .and_then(render_to_js) } +#[cfg(target_arch = "wasm32")] +/// Checks source using provider packs supplied as caller-owned JavaScript data. +#[wasm_bindgen(js_name = checkWithProviderPacks, skip_typescript)] +pub fn check_with_provider_packs_js( + source: JsValue, + provider_packs: JsValue, +) -> Result { + let provider_packs = provider_packs_json(provider_packs)?; + check_with_provider_packs_bytes(&source_bytes(source)?, &provider_packs) + .map_err(operation_error) + .and_then(check_to_js) +} + +#[cfg(target_arch = "wasm32")] +/// Renders source using provider packs supplied as caller-owned JavaScript data. +#[wasm_bindgen(js_name = renderWithProviderPacks, skip_typescript)] +pub fn render_with_provider_packs_js( + source: JsValue, + provider_packs: JsValue, +) -> Result { + let provider_packs = provider_packs_json(provider_packs)?; + render_with_provider_packs_bytes(&source_bytes(source)?, &provider_packs) + .map_err(operation_error) + .and_then(render_to_js) +} + +#[cfg(target_arch = "wasm32")] +fn provider_packs_json(provider_packs: JsValue) -> Result { + JSON::stringify(&provider_packs) + .map_err(|_| TypeError::new("Provider packs must be JSON-compatible local data"))? + .as_string() + .ok_or_else(|| TypeError::new("Provider packs must be JSON-compatible local data").into()) +} + #[cfg(target_arch = "wasm32")] fn source_bytes(source: JsValue) -> Result, JsValue> { if let Some(source) = source.as_string() { @@ -411,6 +611,39 @@ fn render_to_js(result: RenderResult) -> Result { diagnostics_to_js(result.diagnostics)?, )?; set(&output, "metadata", metadata_to_js(result.metadata)?)?; + set( + &output, + "providerNotices", + provider_notices_to_js(result.provider_notices)?, + )?; + Ok(output.into()) +} + +#[cfg(target_arch = "wasm32")] +fn provider_notices_to_js(notices: Vec) -> Result { + let output = Array::new(); + for notice in notices { + let item = Object::new(); + set(&item, "providerId", notice.provider_id.into())?; + set(&item, "providerName", notice.provider_name.into())?; + set(&item, "packVersion", notice.pack_version.into())?; + set(&item, "packRevision", notice.pack_revision.into())?; + set(&item, "sourceRelease", notice.source_release.into())?; + set(&item, "archiveSha256", notice.archive_sha256.into())?; + set(&item, "termsUrl", notice.terms_url.into())?; + set(&item, "attribution", notice.attribution.into())?; + set(&item, "termsSummary", notice.terms_summary.into())?; + set(&item, "nonEndorsement", notice.non_endorsement.into())?; + let icons = Array::new(); + for icon in notice.icons { + let icon_item = Object::new(); + set(&icon_item, "id", icon.id.into())?; + set(&icon_item, "productName", icon.product_name.into())?; + icons.push(&icon_item); + } + set(&item, "icons", icons.into())?; + output.push(&item); + } Ok(output.into()) } @@ -518,7 +751,10 @@ fn set(object: &Object, name: &str, value: JsValue) -> Result<(), JsValue> { mod tests { use std::error::Error; - use super::{Diagnostic, Severity, check_bytes, format_bytes, render_bytes}; + use super::{ + Diagnostic, Severity, check_bytes, check_with_provider_packs_bytes, format_bytes, + render_bytes, render_with_provider_packs_bytes, + }; #[test] fn native_results_keep_operation_shapes_and_invalid_utf8() -> Result<(), Box> { @@ -575,4 +811,24 @@ mod tests { assert_eq!(converted.related[0].message, "requested here"); assert_eq!(converted.related[0].range.start.byte_offset, 1); } + + #[test] + fn provider_pack_helpers_match_the_native_engine_contract() -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind queue icon \"example:storage\" } }"; + let packs = include_str!("../../../tests/fixtures/provider-pack-input.json"); + let checked = check_with_provider_packs_bytes(source, packs)?; + let rendered = render_with_provider_packs_bytes(source, packs)?; + assert!(checked.diagnostics.is_empty()); + assert!(rendered.diagnostics.is_empty()); + assert_eq!(rendered.provider_notices[0].provider_id, "example"); + assert_eq!(rendered.provider_notices[0].icons[0].id, "example:storage"); + assert!( + rendered + .svg + .ok_or("missing provider SVG")? + .contains("data-icon-id=\"example:storage\"") + ); + assert!(check_with_provider_packs_bytes(source, "not json").is_err()); + Ok(()) + } } diff --git a/crates/stack-engine/Cargo.toml b/crates/stack-engine/Cargo.toml index 30e7627..26eaa83 100644 --- a/crates/stack-engine/Cargo.toml +++ b/crates/stack-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -11,10 +11,16 @@ description = "Pure execution facade for Stack diagram operations" conformance = [] [dependencies] +roxmltree.workspace = true +serde_json.workspace = true +sha2.workspace = true stack-compiler.workspace = true stack-formatter.workspace = true stack-theme.workspace = true +[dev-dependencies] +serde.workspace = true + [lints.clippy] expect_used = "deny" panic = "deny" diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index ee1e8e0..cf71961 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -29,6 +29,9 @@ mod routing; mod scene; mod svg; +mod provider; +pub use provider::{ProviderAsset, ProviderPack}; + /// Version of the Rust engine facade. pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -40,6 +43,7 @@ pub type OperationResult = Result; pub struct Engine<'catalog> { catalog: &'catalog stack_theme::Catalog, catalog_revision: &'catalog str, + provider_packs: &'catalog [ProviderPack], } #[derive(Debug)] @@ -56,6 +60,7 @@ impl Engine<'static> { Self { catalog: stack_theme::catalog(), catalog_revision: stack_theme::CATALOG_REVISION, + provider_packs: &[], } } } @@ -67,6 +72,18 @@ impl Default for Engine<'static> { } impl<'catalog> Engine<'catalog> { + /// Creates an engine backed by the bundled catalog and validated provider packs. + /// + /// Provider assets remain caller-owned in-memory data. The engine performs + /// no discovery, file access, download, upload, caching, or terms decision. + pub fn with_provider_packs(provider_packs: &'catalog [ProviderPack]) -> OperationResult { + Self::with_catalog_and_provider_packs( + stack_theme::catalog(), + stack_theme::CATALOG_REVISION, + provider_packs, + ) + } + /// Creates an engine from a previously validated catalog and its content revision. /// /// The catalog document must already have passed the public `stack-theme` @@ -76,6 +93,18 @@ impl<'catalog> Engine<'catalog> { pub fn with_catalog( catalog: &'catalog stack_theme::Catalog, catalog_revision: &'catalog str, + ) -> OperationResult { + Self::with_catalog_and_provider_packs(catalog, catalog_revision, &[]) + } + + /// Creates an engine from a validated catalog and validated provider packs. + /// + /// Provider namespaces must be unique. Provider icons cannot replace core + /// catalog identifiers because [`ProviderPack::new`] requires namespaced IDs. + pub fn with_catalog_and_provider_packs( + catalog: &'catalog stack_theme::Catalog, + catalog_revision: &'catalog str, + provider_packs: &'catalog [ProviderPack], ) -> OperationResult { if !valid_catalog_revision(catalog_revision) { return Err(OperationalError::InvalidCatalog { @@ -102,9 +131,26 @@ impl<'catalog> Engine<'catalog> { }); } + if provider_packs.len() > 32 { + return Err(OperationalError::InvalidProviderPack { + reason: "an engine may contain at most 32 provider packs", + }); + } + for (index, pack) in provider_packs.iter().enumerate() { + if provider_packs[..index] + .iter() + .any(|candidate| candidate.manifest().provider.id == pack.manifest().provider.id) + { + return Err(OperationalError::InvalidProviderPack { + reason: "provider namespaces must be unique", + }); + } + } + Ok(Self { catalog, catalog_revision, + provider_packs, }) } @@ -149,6 +195,7 @@ impl<'catalog> Engine<'catalog> { svg: None, diagnostics: portable_diagnostics(compiled.diagnostics), metadata, + provider_notices: Vec::new(), }); } @@ -174,6 +221,7 @@ impl<'catalog> Engine<'catalog> { svg: Some(svg), diagnostics, metadata, + provider_notices: prepared.resources.provider_notices(), }) } @@ -191,10 +239,9 @@ impl<'catalog> Engine<'catalog> { diagram: &stack_compiler::ir::Diagram, source_map: &stack_compiler::source_map::SourceMap, ) -> OperationResult> { - let resources = resources::Resources::resolve(diagram, self.catalog).map_err(|error| { - OperationalError::InvalidCatalog { - reason: error.reason(), - } + let resources = resources::Resources::resolve(diagram, self.catalog, self.provider_packs) + .map_err(|error| OperationalError::InvalidCatalog { + reason: error.reason(), })?; let scene = scene::layout(diagram, self.catalog).map_err(|error| { OperationalError::InvalidIntermediateRepresentation { @@ -301,6 +348,11 @@ pub enum OperationalError { /// Stable explanation of the violated catalog invariant. reason: &'static str, }, + /// A provider pack violates safe deterministic rendering invariants. + InvalidProviderPack { + /// Stable explanation of the violated provider-pack invariant. + reason: &'static str, + }, /// Compiler or layout data violates an invariant required by pure execution. InvalidIntermediateRepresentation { /// Stable explanation of the violated invariant. @@ -312,6 +364,9 @@ impl fmt::Display for OperationalError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidCatalog { reason } => write!(formatter, "invalid theme catalog: {reason}"), + Self::InvalidProviderPack { reason } => { + write!(formatter, "invalid provider pack: {reason}") + } Self::InvalidIntermediateRepresentation { reason } => { write!(formatter, "invalid intermediate representation: {reason}") } @@ -372,6 +427,44 @@ pub struct RenderOutput { pub diagnostics: Vec, /// Versions that identify the exact operation implementation and inputs. pub metadata: EngineMetadata, + /// Provider-specific notices for the exact assets embedded in this output. + pub provider_notices: Vec, +} + +/// Notice and provenance for one provider pack used by a rendered artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderNotice { + /// Stable provider namespace. + pub provider_id: String, + /// Human-readable provider name. + pub provider_name: String, + /// Provider-pack semantic version. + pub pack_version: String, + /// Deterministic hash of the manifest and processed asset bytes. + pub pack_revision: String, + /// Audited upstream release identifier. + pub source_release: String, + /// Complete official source archive SHA-256. + pub archive_sha256: String, + /// Provider terms reviewed for this pack. + pub terms_url: String, + /// User-visible attribution text. + pub attribution: String, + /// User-visible terms summary. + pub terms_summary: String, + /// User-visible non-endorsement statement. + pub non_endorsement: String, + /// Exact provider icons embedded in the output. + pub icons: Vec, +} + +/// One provider icon listed in a rendered-artifact notice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderNoticeIcon { + /// Namespaced provider icon identifier. + pub id: String, + /// Official provider product name. + pub product_name: String, } /// Engine-owned portable diagnostic shared by native and future WASM outputs. @@ -884,5 +977,9 @@ mod tests { OperationalError::InvalidIntermediateRepresentation { reason: "reason" }.to_string(), "invalid intermediate representation: reason" ); + assert_eq!( + OperationalError::InvalidProviderPack { reason: "reason" }.to_string(), + "invalid provider pack: reason" + ); } } diff --git a/crates/stack-engine/src/provider.rs b/crates/stack-engine/src/provider.rs new file mode 100644 index 0000000..86369f9 --- /dev/null +++ b/crates/stack-engine/src/provider.rs @@ -0,0 +1,708 @@ +//! Validated caller-owned provider packs for pure rendering. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +use roxmltree::{Document, NodeType}; +use sha2::{Digest, Sha256}; +use stack_theme::{ + ProviderIcon, ProviderPack as ProviderPackManifest, ProviderPackPermittedOutput, +}; + +use crate::{OperationResult, OperationalError}; + +const MAX_PROVIDER_ASSET_BYTES: usize = 1024 * 1024; +const MAX_PROVIDER_PACK_BYTES: usize = 32 * 1024 * 1024; +const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg"; +const ALLOWED_ELEMENTS: &[&str] = &[ + "circle", + "defs", + "ellipse", + "g", + "line", + "linearGradient", + "path", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "svg", +]; +const ALLOWED_ATTRIBUTES: &[&str] = &[ + "aria-hidden", + "clip-rule", + "cx", + "cy", + "d", + "fill", + "fill-rule", + "gradientTransform", + "gradientUnits", + "height", + "id", + "opacity", + "offset", + "points", + "r", + "role", + "rx", + "ry", + "stop-color", + "stop-opacity", + "stroke", + "stroke-linecap", + "stroke-linejoin", + "stroke-width", + "transform", + "viewBox", + "width", + "x", + "x1", + "x2", + "y", + "y1", + "y2", +]; + +/// One caller-owned processed SVG addressed by its provider-manifest path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAsset { + path: String, + svg: String, +} + +impl ProviderAsset { + /// Creates one in-memory provider asset without reading host state. + #[must_use] + pub fn new(path: impl Into, svg: impl Into) -> Self { + Self { + path: path.into(), + svg: svg.into(), + } + } + + /// Returns the manifest-relative asset path. + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + /// Returns the complete processed SVG document. + #[must_use] + pub fn svg(&self) -> &str { + &self.svg + } +} + +/// One validated, content-addressed provider pack held entirely in memory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderPack { + manifest: ProviderPackManifest, + revision: String, + assets: Vec, +} + +impl ProviderPack { + /// Validates a typed manifest and its exact processed assets. + /// + /// The host remains responsible for JSON Schema validation, terms review, + /// and local file selection. This constructor rechecks the invariants needed + /// for safe deterministic embedding and computes a content revision without + /// filesystem, network, clock, or environment access. + pub fn new( + manifest: ProviderPackManifest, + assets: Vec, + ) -> OperationResult { + validate_manifest_boundary(&manifest)?; + validate_assets(&manifest, &assets)?; + let revision = pack_revision(&manifest, &assets)?; + Ok(Self { + manifest, + revision, + assets, + }) + } + + /// Returns the validated public provider-pack manifest. + #[must_use] + pub fn manifest(&self) -> &ProviderPackManifest { + &self.manifest + } + + /// Returns the deterministic manifest-and-assets SHA-256 revision. + #[must_use] + pub fn revision(&self) -> &str { + &self.revision + } + + pub(crate) fn icon(&self, identifier: &str) -> Option<(&ProviderIcon, &str)> { + let icon = self + .manifest + .icons + .iter() + .find(|icon| icon.id == identifier)?; + let asset = self + .assets + .iter() + .find(|asset| asset.path == icon.asset.path)?; + Some((icon, &asset.svg)) + } +} + +fn validate_manifest_boundary(manifest: &ProviderPackManifest) -> OperationResult<()> { + let redistribution = &manifest.rights.redistribution; + let processing = &manifest.rights.processing; + if manifest.schema_version != "1.0" + || manifest.icons.is_empty() + || manifest.icons.len() > 10_000 + || !manifest.rights.terms_acceptance_required + || !manifest + .rights + .permitted_outputs + .contains(&ProviderPackPermittedOutput::ArchitectureDiagram) + || redistribution.cargo + || redistribution.npm + || redistribution.wasm + || redistribution.web_asset + || redistribution.native_binary + || !redistribution.generated_output + || !processing.local_only + || processing.automatic_download + || processing.server_upload + || !processing.preserve_colors + || !processing.preserve_geometry + { + return Err(invalid_pack( + "provider pack violates the user-imported rendering boundary", + )); + } + if !valid_provider_id(&manifest.provider.id) + || !valid_sha256(&manifest.source.archive_sha256) + || manifest.source.terms_url.is_empty() + || manifest.notice.attribution.is_empty() + || manifest.notice.terms_summary.is_empty() + || manifest.notice.non_endorsement.is_empty() + { + return Err(invalid_pack("provider pack identity or notice is invalid")); + } + + let prefix = format!("{}:", manifest.provider.id); + let mut identifiers = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for icon in &manifest.icons { + if !icon.id.starts_with(&prefix) + || !valid_namespaced_icon_id(&icon.id) + || !identifiers.insert(icon.id.as_str()) + || !valid_asset_path(&icon.asset.path) + || !paths.insert(icon.asset.path.as_str()) + || !valid_sha256(&icon.asset.original_sha256) + || !valid_sha256(&icon.asset.processed_sha256) + || icon.asset.view_box[2] <= 0 + || icon.asset.view_box[3] <= 0 + { + return Err(invalid_pack( + "provider pack contains an invalid or duplicate icon record", + )); + } + } + Ok(()) +} + +fn validate_assets( + manifest: &ProviderPackManifest, + assets: &[ProviderAsset], +) -> OperationResult<()> { + if assets.len() != manifest.icons.len() { + return Err(invalid_pack( + "provider pack assets do not match the manifest", + )); + } + let mut by_path = BTreeMap::new(); + let mut total_asset_bytes = 0usize; + for asset in assets { + total_asset_bytes = total_asset_bytes + .checked_add(asset.svg.len()) + .ok_or_else(|| invalid_pack("provider pack assets exceed the total size limit"))?; + if total_asset_bytes > MAX_PROVIDER_PACK_BYTES { + return Err(invalid_pack( + "provider pack assets exceed the total size limit", + )); + } + if !valid_asset_path(&asset.path) + || asset.svg.len() > MAX_PROVIDER_ASSET_BYTES + || by_path + .insert(asset.path.as_str(), asset.svg.as_str()) + .is_some() + { + return Err(invalid_pack( + "provider pack contains an invalid or duplicate asset", + )); + } + } + for icon in &manifest.icons { + let Some(svg) = by_path.get(icon.asset.path.as_str()) else { + return Err(invalid_pack( + "provider pack assets do not match the manifest", + )); + }; + if sha256(svg.as_bytes()) != icon.asset.processed_sha256 { + return Err(invalid_pack( + "provider pack asset hash does not match the manifest", + )); + } + validate_svg(svg, icon.asset.view_box)?; + } + Ok(()) +} + +fn validate_svg(svg: &str, expected_view_box: [i32; 4]) -> OperationResult<()> { + let uppercase = svg.to_ascii_uppercase(); + if uppercase.contains(" continue, + NodeType::Text if node.text().is_some_and(|text| text.trim().is_empty()) => continue, + NodeType::Element => {} + _ => return Err(unsafe_svg()), + } + let name = node.tag_name().name(); + let parent_name = node.parent_element().map(|parent| parent.tag_name().name()); + if !ALLOWED_ELEMENTS.contains(&name) + || node.tag_name().namespace() != Some(SVG_NAMESPACE) + || (name == "svg" && node != root) + || (name == "defs" && parent_name != Some("svg")) + || (matches!(name, "linearGradient" | "radialGradient") && parent_name != Some("defs")) + || (name == "stop" && !matches!(parent_name, Some("linearGradient" | "radialGradient"))) + || (parent_name == Some("defs") && !matches!(name, "linearGradient" | "radialGradient")) + { + return Err(unsafe_svg()); + } + for attribute in node.attributes() { + let attribute_name = attribute.name(); + if attribute.namespace().is_some() + || attribute_name.starts_with("on") + || !ALLOWED_ATTRIBUTES.contains(&attribute_name) + { + return Err(unsafe_svg()); + } + if attribute_name == "id" + && (!matches!(name, "linearGradient" | "radialGradient") + || !attribute.value().starts_with("stack-") + || !declared.insert(attribute.value())) + { + return Err(unsafe_svg()); + } + if let Some(identifier) = local_reference(attribute.value()) { + if !matches!(attribute_name, "fill" | "stroke") { + return Err(unsafe_svg()); + } + referenced.insert(identifier); + } else if contains_unsafe_reference(attribute.value()) { + return Err(unsafe_svg()); + } + } + } + if referenced + .iter() + .any(|identifier| !declared.contains(identifier)) + { + return Err(unsafe_svg()); + } + Ok(()) +} + +fn pack_revision( + manifest: &ProviderPackManifest, + assets: &[ProviderAsset], +) -> OperationResult { + let manifest = serde_json::to_vec(manifest) + .map_err(|_| invalid_pack("provider pack manifest cannot be serialized"))?; + let mut digest = Sha256::new(); + digest.update(b"stack-provider-pack-v1\0"); + digest.update(manifest); + let mut assets = assets.iter().collect::>(); + assets.sort_by(|left, right| left.path.cmp(&right.path)); + for asset in assets { + digest.update(b"asset\0"); + digest.update(asset.path.as_bytes()); + digest.update(b"\0"); + digest.update(asset.svg.as_bytes()); + } + Ok(prefixed_digest(digest.finalize())) +} + +fn sha256(bytes: &[u8]) -> String { + prefixed_digest(Sha256::digest(bytes)) +} + +fn prefixed_digest(digest: impl AsRef<[u8]>) -> String { + let mut output = String::from("sha256:"); + for byte in digest.as_ref() { + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn parse_view_box(value: Option<&str>) -> Option<[i32; 4]> { + let value = value?; + let values = value + .split(|character: char| character.is_ascii_whitespace() || character == ',') + .filter(|value| !value.is_empty()) + .map(str::parse::) + .collect::, _>>() + .ok()?; + (values.len() == 4 && values[2] > 0 && values[3] > 0) + .then(|| [values[0], values[1], values[2], values[3]]) +} + +fn local_reference(value: &str) -> Option<&str> { + value + .strip_prefix("url(#") + .and_then(|value| value.strip_suffix(')')) + .filter(|value| !value.is_empty()) +} + +fn contains_unsafe_reference(value: &str) -> bool { + let lowercase = value.to_ascii_lowercase(); + lowercase.contains("url(") + || lowercase.contains("javascript:") + || lowercase.contains("data:") + || lowercase.contains("http://") + || lowercase.contains("https://") + || lowercase.contains("//") +} + +fn valid_provider_id(value: &str) -> bool { + (2..=32).contains(&value.len()) + && value.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte.is_ascii_lowercase() + } else { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + } + }) +} + +fn valid_namespaced_icon_id(value: &str) -> bool { + let Some((provider, slug)) = value.split_once(':') else { + return false; + }; + valid_provider_id(provider) + && (1..=64).contains(&slug.len()) + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn valid_asset_path(value: &str) -> bool { + value.starts_with("assets/") + && value.ends_with(".svg") + && !value.contains('\0') + && !value.split('/').any(|component| component == "..") +} + +fn valid_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) +} + +fn invalid_pack(reason: &'static str) -> OperationalError { + OperationalError::InvalidProviderPack { reason } +} + +fn unsafe_svg() -> OperationalError { + invalid_pack("provider pack asset contains unsafe or unsupported SVG") +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use serde::Deserialize; + + use super::*; + use crate::{Engine, Severity}; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct FixtureInput { + manifest: ProviderPackManifest, + assets: Vec, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct FixtureAsset { + path: String, + svg: String, + } + + fn fixture_input() -> Result> { + let mut inputs: Vec = serde_json::from_str(include_str!( + "../../../tests/fixtures/provider-pack-input.json" + ))?; + inputs + .pop() + .ok_or_else(|| "missing provider fixture".into()) + } + + fn fixture_pack() -> Result> { + let input = fixture_input()?; + Ok(ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + )?) + } + + fn pack_with_svg(svg: &str) -> Result> { + let mut input = fixture_input()?; + let digest = sha256(svg.as_bytes()); + input.manifest.icons[0].asset.original_sha256 = digest.clone(); + input.manifest.icons[0].asset.processed_sha256 = digest; + input.assets[0].svg = svg.to_owned(); + Ok(ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + )?) + } + + #[test] + fn valid_pack_is_content_addressed_and_renders_without_changing_kind() + -> Result<(), Box> { + let pack = fixture_pack()?; + assert_eq!(pack.manifest().provider.id, "example"); + assert!(valid_sha256(pack.revision())); + assert_eq!(pack.assets[0].path(), "assets/storage.svg"); + assert!(pack.assets[0].svg().contains("#4285f4")); + + let packs = [pack]; + let engine = Engine::with_provider_packs(&packs)?; + let source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind queue icon \"example:storage\" } }"; + let checked = engine.check(source)?; + let first = engine.render(source)?; + let second = engine.render(source)?; + assert!(checked.diagnostics.is_empty()); + assert_eq!(first, second); + let svg = first.svg.ok_or("missing rendered SVG")?; + assert!(svg.contains("data-node-kind=\"queue\"")); + assert!(svg.contains("data-icon-id=\"example:storage\"")); + assert!(svg.contains("fill=\"#4285f4\"")); + assert!(svg.contains(packs[0].revision())); + assert_eq!(first.provider_notices.len(), 1); + assert_eq!(first.provider_notices[0].provider_id, "example"); + assert_eq!(first.provider_notices[0].icons[0].id, "example:storage"); + assert_eq!( + first.provider_notices[0].icons[0].product_name, + "Example Storage" + ); + Ok(()) + } + + #[test] + fn missing_pack_preserves_the_existing_warning_and_fallback() -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Missing\" { node item \"Data\" { kind storage icon \"example:storage\" } }"; + let output = Engine::bundled().render(source)?; + assert_eq!(output.diagnostics.len(), 1); + assert_eq!(output.diagnostics[0].code, "STK5001"); + assert_eq!(output.diagnostics[0].severity, Severity::Warning); + assert!(output.provider_notices.is_empty()); + assert!( + output + .svg + .ok_or("missing fallback SVG")? + .contains("data-icon-id=\"kind-external\"") + ); + Ok(()) + } + + #[test] + fn manifest_boundary_rejects_invalid_rights_identity_and_records() -> Result<(), Box> + { + let input = fixture_input()?; + let assets = input + .assets + .iter() + .map(|asset| ProviderAsset::new(&asset.path, &asset.svg)) + .collect::>(); + + for mutate in [ + |manifest: &mut ProviderPackManifest| manifest.schema_version = "2.0".to_owned(), + |manifest: &mut ProviderPackManifest| manifest.rights.redistribution.cargo = true, + |manifest: &mut ProviderPackManifest| manifest.rights.processing.server_upload = true, + ] { + let mut manifest = input.manifest.clone(); + mutate(&mut manifest); + assert!(matches!( + ProviderPack::new(manifest, assets.clone()), + Err(OperationalError::InvalidProviderPack { .. }) + )); + } + + for mutate in [ + |manifest: &mut ProviderPackManifest| manifest.provider.id = "X".to_owned(), + |manifest: &mut ProviderPackManifest| manifest.source.archive_sha256 = "bad".to_owned(), + |manifest: &mut ProviderPackManifest| manifest.notice.attribution.clear(), + ] { + let mut manifest = input.manifest.clone(); + mutate(&mut manifest); + assert!(matches!( + ProviderPack::new(manifest, assets.clone()), + Err(OperationalError::InvalidProviderPack { .. }) + )); + } + + for mutate in [ + |manifest: &mut ProviderPackManifest| manifest.icons[0].id = "other:storage".to_owned(), + |manifest: &mut ProviderPackManifest| { + manifest.icons[0].asset.path = "../icon.svg".to_owned() + }, + |manifest: &mut ProviderPackManifest| manifest.icons[0].asset.view_box[2] = 0, + ] { + let mut manifest = input.manifest.clone(); + mutate(&mut manifest); + assert!(matches!( + ProviderPack::new(manifest, assets.clone()), + Err(OperationalError::InvalidProviderPack { .. }) + )); + } + Ok(()) + } + + #[test] + fn assets_must_be_exact_unique_small_and_hash_matched() -> Result<(), Box> { + let input = fixture_input()?; + assert!(ProviderPack::new(input.manifest.clone(), Vec::new()).is_err()); + assert!( + ProviderPack::new( + input.manifest.clone(), + vec![ProviderAsset::new("assets/other.svg", &input.assets[0].svg)], + ) + .is_err() + ); + assert!( + ProviderPack::new( + input.manifest.clone(), + vec![ProviderAsset::new( + &input.assets[0].path, + format!("{} ", input.assets[0].svg), + )], + ) + .is_err() + ); + assert!( + ProviderPack::new( + input.manifest, + vec![ProviderAsset::new( + "assets/storage.svg", + "x".repeat(MAX_PROVIDER_ASSET_BYTES + 1), + )], + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn total_asset_bytes_are_bounded() -> Result<(), Box> { + let mut input = fixture_input()?; + let original = input.manifest.icons[0].clone(); + input.manifest.icons = (0..33) + .map(|index| { + let mut icon = original.clone(); + icon.id = format!("example:storage-{index}"); + icon.asset.path = format!("assets/storage-{index}.svg"); + icon + }) + .collect(); + let assets = (0..33) + .map(|index| { + ProviderAsset::new( + format!("assets/storage-{index}.svg"), + "x".repeat(MAX_PROVIDER_ASSET_BYTES), + ) + }) + .collect(); + assert!(matches!( + ProviderPack::new(input.manifest, assets), + Err(OperationalError::InvalidProviderPack { + reason: "provider pack assets exceed the total size limit" + }) + )); + Ok(()) + } + + #[test] + fn unsafe_or_unsupported_svg_is_rejected_after_hash_verification() { + for svg in [ + "", + "", + "", + "", + "", + "", + "", + "visible", + "", + "", + "", + "", + ] { + assert!( + pack_with_svg(svg).is_err(), + "unsafe SVG was accepted: {svg}" + ); + } + } + + #[test] + fn namespaced_local_gradients_are_accepted() { + let svg = ""; + assert!(pack_with_svg(svg).is_ok()); + } + + #[test] + fn duplicate_provider_namespaces_and_excessive_pack_counts_are_rejected() + -> Result<(), Box> { + let pack = fixture_pack()?; + assert!(matches!( + Engine::with_provider_packs(&[pack.clone(), pack.clone()]), + Err(OperationalError::InvalidProviderPack { + reason: "provider namespaces must be unique" + }) + )); + let packs = vec![pack; 33]; + assert!(matches!( + Engine::with_provider_packs(&packs), + Err(OperationalError::InvalidProviderPack { + reason: "an engine may contain at most 32 provider packs" + }) + )); + Ok(()) + } +} diff --git a/crates/stack-engine/src/resources.rs b/crates/stack-engine/src/resources.rs index 9b46d42..a783443 100644 --- a/crates/stack-engine/src/resources.rs +++ b/crates/stack-engine/src/resources.rs @@ -1,7 +1,9 @@ //! Deterministic theme and icon resolution for scene validation and rendering. use stack_compiler::ir::{Diagram, NodeKind}; -use stack_theme::{Catalog, FontMetrics, Icon, NodeVisual, Theme}; +use stack_theme::{Catalog, FontMetrics, NodeVisual, ProviderIcon, Theme}; + +use crate::{ProviderNotice, ProviderNoticeIcon, ProviderPack}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResourceWarning { @@ -24,8 +26,16 @@ impl ResourceError { pub(crate) struct ResolvedNode<'catalog> { pub(crate) node_id: String, pub(crate) visual: &'catalog NodeVisual, - pub(crate) icon: &'catalog Icon, - pub(crate) icon_svg: &'static str, + pub(crate) icon_id: &'catalog str, + pub(crate) icon_view_box: [i32; 4], + pub(crate) icon_svg: &'catalog str, + provider: Option>, +} + +#[derive(Debug)] +struct ResolvedProviderIcon<'catalog> { + pack: &'catalog ProviderPack, + icon: &'catalog ProviderIcon, } #[derive(Debug)] @@ -40,6 +50,7 @@ impl<'catalog> Resources<'catalog> { pub(crate) fn resolve( diagram: &Diagram, catalog: &'catalog Catalog, + provider_packs: &'catalog [ProviderPack], ) -> Result { let requested_theme = catalog .themes @@ -70,6 +81,17 @@ impl<'catalog> Resources<'catalog> { for node in &diagram.nodes { let visual = node_visual(theme, node.kind); let requested_icon = node.icon_id.as_deref().unwrap_or(&visual.fallback_icon_id); + if let Some((pack, icon, svg)) = provider_icon(provider_packs, requested_icon) { + nodes.push(ResolvedNode { + node_id: node.id.clone(), + visual, + icon_id: &icon.id, + icon_view_box: icon.asset.view_box, + icon_svg: svg, + provider: Some(ResolvedProviderIcon { pack, icon }), + }); + continue; + } let icon = match theme.icons.iter().find(|icon| icon.id == requested_icon) { Some(icon) => icon, None if node.icon_id.is_some() => { @@ -97,8 +119,10 @@ impl<'catalog> Resources<'catalog> { nodes.push(ResolvedNode { node_id: node.id.clone(), visual, - icon, + icon_id: &icon.id, + icon_view_box: icon.asset.view_box, icon_svg, + provider: None, }); } @@ -113,6 +137,64 @@ impl<'catalog> Resources<'catalog> { pub(crate) fn node(&self, identifier: &str) -> Option<&ResolvedNode<'catalog>> { self.nodes.iter().find(|node| node.node_id == identifier) } + + pub(crate) fn provider_notices(&self) -> Vec { + let mut notices = Vec::new(); + for node in &self.nodes { + let Some(resolved) = &node.provider else { + continue; + }; + let notice_index = notices + .iter() + .position(|notice: &ProviderNotice| { + notice.provider_id == resolved.pack.manifest().provider.id + }) + .unwrap_or_else(|| { + let manifest = resolved.pack.manifest(); + notices.push(ProviderNotice { + provider_id: manifest.provider.id.clone(), + provider_name: manifest.provider.name.clone(), + pack_version: manifest.pack_version.clone(), + pack_revision: resolved.pack.revision().to_owned(), + source_release: manifest.source.release.clone(), + archive_sha256: manifest.source.archive_sha256.clone(), + terms_url: manifest.source.terms_url.clone(), + attribution: manifest.notice.attribution.clone(), + terms_summary: manifest.notice.terms_summary.clone(), + non_endorsement: manifest.notice.non_endorsement.clone(), + icons: Vec::new(), + }); + notices.len() - 1 + }); + if !notices[notice_index] + .icons + .iter() + .any(|icon| icon.id == resolved.icon.id) + { + notices[notice_index].icons.push(ProviderNoticeIcon { + id: resolved.icon.id.clone(), + product_name: resolved.icon.product_name.clone(), + }); + } + } + notices + } +} + +fn provider_icon<'catalog>( + provider_packs: &'catalog [ProviderPack], + identifier: &str, +) -> Option<( + &'catalog ProviderPack, + &'catalog ProviderIcon, + &'catalog str, +)> { + for pack in provider_packs { + if let Some((icon, svg)) = pack.icon(identifier) { + return Some((pack, icon, svg)); + } + } + None } pub(crate) fn node_visual(theme: &Theme, kind: NodeKind) -> &NodeVisual { diff --git a/crates/stack-engine/src/svg.rs b/crates/stack-engine/src/svg.rs index cd527f1..7182fd4 100644 --- a/crates/stack-engine/src/svg.rs +++ b/crates/stack-engine/src/svg.rs @@ -59,13 +59,37 @@ pub(crate) fn render( counted(diagram.groups.len(), "group", "groups"), counted(diagram.edges.len(), "relationship", "relationships") )); + let provider_metadata = resources + .provider_notices() + .into_iter() + .map(|notice| { + format!( + "{} at {} using {}", + notice.provider_id, + notice.pack_revision, + notice + .icons + .iter() + .map(|icon| icon.id.as_str()) + .collect::>() + .join(",") + ) + }) + .collect::>() + .join("; "); + let provider_metadata = if provider_metadata.is_empty() { + String::new() + } else { + format!("; providers {provider_metadata}") + }; output.push_str(&format!( - " stack-engine {}; language {}.{}; theme {} at {}\n", + " stack-engine {}; language {}.{}; theme {} at {}{}\n", escape_text(&metadata.engine_version), metadata.language_version.map_or(0, |version| version.major), metadata.language_version.map_or(0, |version| version.minor), escape_text(&metadata.theme_catalog_version), - escape_text(&metadata.theme_catalog_revision) + escape_text(&metadata.theme_catalog_revision), + escape_text(&provider_metadata) )); output.push_str(&format!( " \n", @@ -520,7 +544,7 @@ fn render_icon( let body = embedded_svg_body(resolved.icon_svg).ok_or(SvgError { reason: "embedded icon is not a complete SVG document", })?; - let view_box = resolved.icon.asset.view_box; + let view_box = resolved.icon_view_box; output.push_str(&format!( " {} \n", rect.x + NODE_HORIZONTAL_PADDING, @@ -532,7 +556,7 @@ fn render_icon( view_box[2], view_box[3], escape_attribute(palette_color(theme, resolved.visual.accent)), - escape_attribute(&resolved.icon.id), + escape_attribute(resolved.icon_id), body.trim() )); Ok(()) diff --git a/crates/stack-engine/tests/snapshots/render/complete-semantics.svg b/crates/stack-engine/tests/snapshots/render/complete-semantics.svg index 44d0d30..e684aa9 100644 --- a/crates/stack-engine/tests/snapshots/render/complete-semantics.svg +++ b/crates/stack-engine/tests/snapshots/render/complete-semantics.svg @@ -1,8 +1,8 @@ - + Complete semantics Architecture diagram with 10 nodes, 3 groups, and 8 relationships. - stack-engine 0.3.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e + stack-engine 0.4.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e diff --git a/crates/stack-engine/tests/snapshots/render/default-normalization.svg b/crates/stack-engine/tests/snapshots/render/default-normalization.svg index 3bf8bbb..bb1fd5b 100644 --- a/crates/stack-engine/tests/snapshots/render/default-normalization.svg +++ b/crates/stack-engine/tests/snapshots/render/default-normalization.svg @@ -1,8 +1,8 @@ - + Default normalization Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.3.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e + stack-engine 0.4.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e diff --git a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg index 988eb2f..a3b67f6 100644 --- a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg +++ b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg @@ -1,8 +1,8 @@ - + Core icon Architecture diagram with 1 node, 0 groups, and 0 relationships. - stack-engine 0.3.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e + stack-engine 0.4.0; language 1.0; theme 0.3.0 at sha256:e4eaad0813fcfef4a203e861909ff38833270646f9097155974c7c92108c5b1e diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md index d476434..709b78a 100644 --- a/docs/dependency-audit.md +++ b/docs/dependency-audit.md @@ -4,19 +4,20 @@ Audit date: 2026-09-04 ## Runtime graph -`stack-engine` has three direct dependencies: +`stack-engine` has six direct dependencies: -- `stack-compiler` at `3d2379483da1edaeb24a26d43743587a4f5bd645` for byte decoding, parsing, validation, normalized IR, source maps, and compiler diagnostics; +- `stack-compiler` at `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` for byte decoding, parsing, validation, normalized IR, source maps, and compiler diagnostics; - the workspace-local `stack-formatter` for canonical source output; -- `stack-theme` at `5dbe41326370260cfc6b72d4aab4470318d66dab` for the `0.3.0` embedded core catalog, 12 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision. Vendor asset bytes are not included. +- `stack-theme` at `5dbe41326370260cfc6b72d4aab4470318d66dab` for the `0.3.0` embedded core catalog, 12 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision; +- `roxmltree`, `serde_json`, and `sha2` for pure in-memory provider manifest serialization, processed-asset hash verification, pack revision computation, and defensive SVG validation. Vendor asset bytes are not included. -`stack-engine-wasm` adds `serde` for its serializable native parity model and, only on `wasm32`, version-matched `wasm-bindgen` and `js-sys` for the JavaScript ABI, typed-array input, and plain object construction. It does not use `web-sys` or a WASI target. +`stack-engine-wasm` adds `serde`, `serde_json`, and the asset-free `stack-theme` types for its serializable native parity model and local provider-pack input, plus, only on `wasm32`, version-matched `wasm-bindgen` and `js-sys` for the JavaScript ABI, typed-array input, JSON-compatible local data, and plain object construction. It does not use `web-sys` or a WASI target. -The resolved normal dependency graph adds only the `serde` and `serde_json` graph required by `stack-theme`. Exact versions and licenses are recorded in [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md) and pinned in `Cargo.lock`. Scene layout is implemented locally with fixed-width integer arithmetic and versioned catalog metrics. Standalone SVG serialization is also local and embeds only validated catalog icon bodies and local marker references. No third-party layout or SVG serializer, filesystem, network, asynchronous runtime, random, clock, locale, DOM, or platform-font dependency is present. +Exact versions and licenses are recorded in [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md) and pinned in `Cargo.lock`. Scene layout is implemented locally with fixed-width integer arithmetic and versioned catalog metrics. Standalone SVG serialization is also local and embeds only validated catalog or caller-owned provider icon bodies and local marker references. No third-party layout or SVG serializer, filesystem, network, asynchronous runtime, random, clock, locale, DOM, or platform-font dependency is present. ## Runtime access boundary -- `stack-engine` accepts source bytes plus a borrowed validated catalog and revision, or selects the catalog already embedded by `stack-theme`. +- `stack-engine` accepts source bytes plus a borrowed validated catalog and revision, or selects the catalog already embedded by `stack-theme`. It may also accept validated caller-owned provider manifests and SVG strings; it hashes and validates them entirely in memory. - `stack-compiler` and `stack-formatter` operate entirely on caller-owned bytes and in-memory values. - `stack-theme` embeds catalog, schema, and SVG bytes at compile time and parses the trusted generated catalog through an in-memory singleton. - No runtime dependency discovers a path, opens a socket, reads process state, observes time, samples randomness, queries a DOM, or measures a system font. diff --git a/package-lock.json b/package-lock.json index d0697ef..92a739a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-engine-workspace", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-engine-workspace", - "version": "0.3.0", + "version": "0.4.0", "workspaces": [ "packages/engine" ], @@ -395,7 +395,7 @@ }, "packages/engine": { "name": "@stack-sh/engine", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index 3163167..432c2f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-engine-workspace", "private": true, - "version": "0.3.0", + "version": "0.4.0", "workspaces": [ "packages/engine" ], diff --git a/packages/engine/README.md b/packages/engine/README.md index 96789f6..d72a816 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -3,7 +3,7 @@ Browser WebAssembly adapter for the pure Stack diagram engine. ```js -import init, { check, format, render } from "@stack-sh/engine"; +import init, { check, format, render, renderWithProviderPacks } from "@stack-sh/engine"; await init(); @@ -12,6 +12,8 @@ const checked = check(new TextEncoder().encode('stack 1.0 diagram "API" { node a const rendered = render('stack 1.0 diagram "API" { node api "API" { icon "api" } }'); ``` +`renderWithProviderPacks(source, packs)` and `checkWithProviderPacks(source, packs)` accept JSON-compatible, caller-owned provider manifests and processed SVG strings. They resolve namespaced identifiers such as `example:storage`, preserve the authored node kind, and return provider notices containing the exact pack revision, source release, archive hash, terms URL, and used icons. The adapter performs no filesystem, network, storage, clock, or DOM access. + Each operation is synchronous after module initialization and accepts either a JavaScript string or `Uint8Array`. Invalid Stack source, including invalid UTF-8 bytes, returns normal portable diagnostics. Diagnostics include the primary range, ordered `expected` values, corrective help, and related source locations. A JavaScript value of any other type throws `TypeError` at the package boundary. The package does not read files, contact a network service, inspect the DOM, observe a clock, or measure host fonts. Consumers own module loading and all host I/O. diff --git a/packages/engine/THIRD_PARTY_LICENSES.md b/packages/engine/THIRD_PARTY_LICENSES.md index 7afa18c..640f104 100644 --- a/packages/engine/THIRD_PARTY_LICENSES.md +++ b/packages/engine/THIRD_PARTY_LICENSES.md @@ -4,8 +4,11 @@ | Component | Version / revision | Selected license | Source | | --- | --- | --- | --- | -| `stack-compiler` | `3d2379483da1edaeb24a26d43743587a4f5bd645` | Apache-2.0 | | +| `stack-compiler` | `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` | Apache-2.0 | | | `stack-theme` | `5dbe41326370260cfc6b72d4aab4470318d66dab` | Apache-2.0 | | +| `roxmltree` | `0.21.1` | Apache-2.0 | | +| `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | Apache-2.0 | | +| `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | Apache-2.0 | , | | `serde` / `serde_core` | `1.0.229` | Apache-2.0 | | | `serde_json` | `1.0.151` | Apache-2.0 | | | `itoa` | `1.0.18` | Apache-2.0 | | diff --git a/packages/engine/package.json b/packages/engine/package.json index ed82e7b..04d1ff8 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/engine", - "version": "0.3.0", + "version": "0.4.0", "description": "Browser WebAssembly adapter for Stack diagram operations", "type": "module", "license": "Apache-2.0", diff --git a/scripts/validate-wasm-package.mjs b/scripts/validate-wasm-package.mjs index 5ee6523..0842650 100644 --- a/scripts/validate-wasm-package.mjs +++ b/scripts/validate-wasm-package.mjs @@ -19,6 +19,8 @@ assert.match(declaration, /export type StackSource = string \| Uint8Array;/); assert.match(declaration, /export function format\(source: StackSource\): FormatResult;/); assert.match(declaration, /export function check\(source: StackSource\): CheckResult;/); assert.match(declaration, /export function render\(source: StackSource\): RenderResult;/); +assert.match(declaration, /export function checkWithProviderPacks/); +assert.match(declaration, /export function renderWithProviderPacks/); const module = new WebAssembly.Module(binary); const imports = WebAssembly.Module.imports(module); @@ -38,12 +40,12 @@ assert.doesNotMatch( importGlue, /\b(?:eval|Function|fetch|XMLHttpRequest|WebSocket|document|window|navigator|location|localStorage|sessionStorage|Date|performance|crypto|process|require|setTimeout|setInterval)\b/, ); -for (const requiredPrimitive of ["Array", "Error", "Object", "Reflect", "TypeError", "Uint8Array"]) { +for (const requiredPrimitive of ["Array", "Error", "JSON", "Object", "Reflect", "TypeError", "Uint8Array"]) { assert.match(importGlue, new RegExp(`\\b${requiredPrimitive}\\b`)); } const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name)); -for (const operation of ["format", "check", "render"]) { +for (const operation of ["format", "check", "render", "checkWithProviderPacks", "renderWithProviderPacks"]) { assert.ok(exports.has(operation), `missing ${operation} WebAssembly export`); } diff --git a/tests/fixtures/provider-pack-input.json b/tests/fixtures/provider-pack-input.json new file mode 100644 index 0000000..a3cc0bd --- /dev/null +++ b/tests/fixtures/provider-pack-input.json @@ -0,0 +1,82 @@ +[ + { + "manifest": { + "$schema": "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json", + "schemaVersion": "1.0", + "packVersion": "0.1.0", + "provider": { + "id": "example", + "name": "Example Cloud" + }, + "distributionMode": "user-imported", + "source": { + "pageUrl": "https://example.com/icons", + "archiveUrl": "https://example.com/icons.zip", + "archiveSha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "release": "fixture-1", + "retrievedAt": "2026-09-04", + "termsUrl": "https://example.com/terms", + "termsReviewedAt": "2026-09-04", + "reviewAfter": "2026-12-03", + "copyright": "Copyright Example Cloud", + "licenseId": "LicenseRef-Example-Icons", + "archiveLicenseIncluded": false + }, + "rights": { + "termsAcceptanceRequired": true, + "permittedOutputs": [ + "architecture-diagram" + ], + "redistribution": { + "cargo": false, + "npm": false, + "wasm": false, + "webAsset": false, + "nativeBinary": false, + "generatedOutput": true + }, + "processing": { + "localOnly": true, + "automaticDownload": false, + "serverUpload": false, + "preserveColors": true, + "preserveGeometry": true, + "productNameNearby": true + }, + "modificationPolicy": "visual-preservation-only" + }, + "notice": { + "attribution": "Example Cloud owns the fixture icon.", + "termsSummary": "Architecture diagram use only.", + "nonEndorsement": "Example Cloud does not endorse Stack." + }, + "icons": [ + { + "id": "example:storage", + "subject": "Object storage service", + "productName": "Example Storage", + "recommendedNodeKind": "storage", + "asset": { + "path": "assets/storage.svg", + "originalPath": "icons/storage.svg", + "viewBox": [ + 0, + 0, + 24, + 24 + ], + "originalSha256": "sha256:836062321764dff3fa57b77f24c5ea13a9cec8833bc3c1796c21a7af21f5d4ad", + "processedSha256": "sha256:836062321764dff3fa57b77f24c5ea13a9cec8833bc3c1796c21a7af21f5d4ad", + "transformations": [] + } + } + ] + }, + "assets": [ + { + "path": "assets/storage.svg", + "svg": "\n" + } + ] + } +] diff --git a/tests/specification-revision b/tests/specification-revision index 20d6940..25d3046 100644 --- a/tests/specification-revision +++ b/tests/specification-revision @@ -1 +1 @@ -7f9154d22702ddf02f2713bbc06dde7bdf635806 +80df39151df67eedb4cfcb2de9f964129cd87466 diff --git a/tests/types.test.ts b/tests/types.test.ts index 97ee92a..f8fee61 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -1,10 +1,13 @@ import init, { check, + checkWithProviderPacks, format, render, + renderWithProviderPacks, type CheckResult, type Diagnostic, type FormatResult, + type ProviderPackInput, type RenderResult, type StackSource, } from "@stack-sh/engine"; @@ -16,11 +19,16 @@ const formatted: FormatResult = format(text); const checked: CheckResult = check(bytes); const rendered: RenderResult = render(text); const diagnostic: Diagnostic | undefined = checked.diagnostics[0]; +const providerPacks = JSON.parse("[]") as readonly ProviderPackInput[]; +const providerChecked: CheckResult = checkWithProviderPacks(text, providerPacks); +const providerRendered: RenderResult = renderWithProviderPacks(bytes, providerPacks); formatted.formattedSource?.toUpperCase(); rendered.svg?.startsWith(" { ); assert.ok(actionable); assert.equal(actionable.render.svg, null); - assert.equal(actionable.check.metadata.engineVersion, "0.3.0"); + assert.equal(actionable.check.metadata.engineVersion, "0.4.0"); assert.deepEqual(actionable.check.diagnostics[0], { code: "STK2002", severity: "error", @@ -94,7 +103,7 @@ test("browser rendering resolves the bundled explicit core icon", () => { assert.ok(explicitIcon); assert.deepEqual(explicitIcon.check.diagnostics, []); assert.deepEqual(explicitIcon.render.diagnostics, []); - assert.equal(explicitIcon.render.metadata.engineVersion, "0.3.0"); + assert.equal(explicitIcon.render.metadata.engineVersion, "0.4.0"); assert.equal(explicitIcon.render.metadata.themeCatalogVersion, "0.3.0"); assert.equal( explicitIcon.render.metadata.themeCatalogRevision, @@ -104,6 +113,24 @@ test("browser rendering resolves the bundled explicit core icon", () => { assert.doesNotMatch(explicitIcon.render.svg, /data-icon-id="kind-external"/); }); +test("browser rendering resolves local provider packs with native provenance", () => { + const source = + 'stack 1.0 diagram "Provider" { node item "Example Storage" { kind queue icon "example:storage" } }'; + const checked = checkWithProviderPacks(source, providerPacks); + const rendered = renderWithProviderPacks(source, providerPacks); + assert.deepEqual(checked.diagnostics, []); + assert.deepEqual(rendered.diagnostics, []); + assert.match(rendered.svg, /data-node-kind="queue"/); + assert.match(rendered.svg, /data-icon-id="example:storage"/); + assert.match(rendered.svg, /fill="#4285f4"/); + assert.equal(rendered.providerNotices.length, 1); + assert.equal(rendered.providerNotices[0].providerId, "example"); + assert.deepEqual(rendered.providerNotices[0].icons, [ + { id: "example:storage", productName: "Example Storage" }, + ]); + assert.match(rendered.providerNotices[0].packRevision, /^sha256:[0-9a-f]{64}$/); +}); + test("the JavaScript boundary rejects unsupported source values consistently", () => { for (const operation of [format, check, render]) { assert.throws( @@ -111,4 +138,8 @@ test("the JavaScript boundary rejects unsupported source values consistently", ( { name: "TypeError", message: "Stack source must be a string or Uint8Array" }, ); } + assert.throws( + () => checkWithProviderPacks("stack 1.0", () => undefined), + { name: "TypeError", message: "Provider packs must be JSON-compatible local data" }, + ); });