From 77b7e8656a700ebad24bff3b12725e30429b432c Mon Sep 17 00:00:00 2001 From: konojunya Date: Fri, 4 Sep 2026 11:46:18 +0900 Subject: [PATCH] Render diagrams with local provider icon packs --- Cargo.lock | 11 +- Cargo.toml | 2 +- README.md | 5 +- THIRD_PARTY_LICENSES.md | 4 +- docs/provider-icon-import.md | 15 + src/lib.rs | 370 ++++++++++++++++-- .../fixtures/provider-pack/assets/storage.svg | 1 + tests/fixtures/provider-pack/manifest.json | 72 ++++ tests/render.rs | 38 ++ tests/specification-revision | 2 +- 10 files changed, 488 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/provider-pack/assets/storage.svg create mode 100644 tests/fixtures/provider-pack/manifest.json diff --git a/Cargo.lock b/Cargo.lock index da75173..e905227 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,13 +235,16 @@ dependencies = [ [[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" -source = "git+https://github.com/stack-sh/engine.git?rev=66a1d183a9b743990a4e0b7139fbe25b8bf5eb08#66a1d183a9b743990a4e0b7139fbe25b8bf5eb08" +version = "0.4.0" +source = "git+https://github.com/stack-sh/engine.git?rev=8b62b0ef77c12b1b88981ea569379d3d9737a824#8b62b0ef77c12b1b88981ea569379d3d9737a824" dependencies = [ + "roxmltree", + "serde_json", + "sha2", "stack-compiler", "stack-formatter", "stack-theme", @@ -250,7 +253,7 @@ dependencies = [ [[package]] name = "stack-formatter" version = "0.1.0" -source = "git+https://github.com/stack-sh/engine.git?rev=66a1d183a9b743990a4e0b7139fbe25b8bf5eb08#66a1d183a9b743990a4e0b7139fbe25b8bf5eb08" +source = "git+https://github.com/stack-sh/engine.git?rev=8b62b0ef77c12b1b88981ea569379d3d9737a824#8b62b0ef77c12b1b88981ea569379d3d9737a824" dependencies = [ "stack-compiler", ] diff --git a/Cargo.toml b/Cargo.toml index 624d57c..54d8091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ path = "src/main.rs" roxmltree = "=0.21.1" serde_json = "=1.0.151" sha2 = "=0.11.0" -stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "66a1d183a9b743990a4e0b7139fbe25b8bf5eb08" } +stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "8b62b0ef77c12b1b88981ea569379d3d9737a824" } stack-theme = { git = "https://github.com/stack-sh/theme.git", rev = "5dbe41326370260cfc6b72d4aab4470318d66dab" } zip = { version = "=6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } diff --git a/README.md b/README.md index 433ae73..3691305 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ stack fmt - stack render arch.stack stack render arch.stack -o arch.svg stack icons import aws ~/Downloads/aws-icons.zip --accept-terms -o .stack-icons/aws +stack render arch.stack --provider-pack .stack-icons/aws -o arch.svg --notice arch.NOTICE.md ``` `stack check` reads the file as bytes and runs the full compiler, theme, layout, and routing validation pipeline without changing the source. Diagnostics are written to standard error in source order. Standard output remains empty. `stack fmt` uses the engine formatter and preserves comments. File mode replaces changed source atomically through a temporary file in the same directory; unchanged files are not replaced. Syntax, encoding, and host I/O failures leave the original file untouched. `stack fmt -` reads bytes from standard input and writes only canonical source to standard output. `--check` never writes source and exits with status `1` when formatting is required. -`stack render` uses the same engine pipeline to produce deterministic standalone SVG. Without `-o`, standard output contains only SVG. With `-o`, the output is written atomically in the destination directory. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. +`stack render` uses the same engine pipeline to produce deterministic standalone SVG. Without `-o`, standard output contains only SVG. With `-o`, the output is written atomically in the destination directory. Repeat `--provider-pack ` to load local imported packs, and use `--notice ` to save the exact provider pack revisions, terms, source archives, and icon IDs embedded in that artifact. Pack files are bounded and validated before rendering; the command performs no download or upload. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. `stack icons import` creates a local provider pack from an official ZIP archive that the user selected. It performs no network request or upload, requires explicit terms acceptance, verifies the complete archive against an audited SHA-256, reads only allowlisted SVG entries with fixed size limits, removes active or external content, preserves the official colors and geometry, and writes the manifest, notice, and processed SVGs atomically to a new directory. The initial audited profiles import 7 AWS, 6 Google Cloud, or 5 Azure icons. See [the provider icon import guide](./docs/provider-icon-import.md) for exact sources, hashes, IDs, and terms. @@ -32,7 +33,7 @@ stack icons import aws ~/Downloads/aws-icons.zip --accept-terms -o .stack-icons/ The CLI links `stack-engine` as a native Rust dependency. It owns filesystem and standard-stream behavior, process exit codes, configuration discovery, provider-pack import, notice output, and command presentation. It must not duplicate compiler, formatter, layout, or SVG-rendering logic. -The bundled engine resolves the provider-neutral core icons `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability`. Vendor assets are not bundled. Local provider-pack import preserves upstream artwork and attaches source, archive hash, transformation, terms, and notice metadata; Engine rendering of those packs is a separate compatibility step. +The bundled engine resolves the provider-neutral core icons `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, and `observability`. Vendor assets are not bundled. Local provider-pack import preserves upstream artwork and attaches source, archive hash, transformation, terms, and notice metadata. Rendering resolves namespaced IDs such as `aws:s3`, preserves the authored semantic `kind`, embeds only the selected local asset, and writes its provenance into SVG metadata and the optional notice sidecar. ## Development diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index e8cfbda..9857c1c 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -6,8 +6,8 @@ Audit date: 2026-09-04 | Component | Version or revision | License | Source | Distribution note | | --- | --- | --- | --- | --- | -| `stack-engine` / `stack-formatter` | `66a1d183a9b743990a4e0b7139fbe25b8bf5eb08` | Apache-2.0 | | Linked into the native binary; includes the repository-authored core icon catalog through `stack-theme`. | -| `stack-compiler` | `3d2379483da1edaeb24a26d43743587a4f5bd645` | Apache-2.0 | | Linked transitively through `stack-engine`. | +| `stack-engine` / `stack-formatter` | `8b62b0ef77c12b1b88981ea569379d3d9737a824` | Apache-2.0 | | Linked into the native binary; validates and renders caller-owned provider packs without bundling vendor assets. | +| `stack-compiler` | `4a18fac42afc2256a1bb3a6ff13d12d732a391e7` | Apache-2.0 | | Linked transitively through `stack-engine`; preserves namespaced provider icon identifiers in normalized IR. | | `stack-theme` | `5dbe41326370260cfc6b72d4aab4470318d66dab` | Apache-2.0 | | Linked directly and through `stack-engine`; its 30 fallback and 12 explicit core SVGs are Stack-authored Apache-2.0 assets. It also provides the asset-free provider-pack contract and types. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses untrusted local SVG into a read-only tree before allowlisted serialization. | | `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 | | Computes complete archive and per-asset SHA-256 identities. | diff --git a/docs/provider-icon-import.md b/docs/provider-icon-import.md index c101590..b77f6bd 100644 --- a/docs/provider-icon-import.md +++ b/docs/provider-icon-import.md @@ -28,6 +28,21 @@ stack icons import aws ~/Downloads/aws-icons.zip \ The manifest follows the public [`stack-sh/theme` provider-pack schema](https://github.com/stack-sh/theme/blob/main/PROVIDER_PACKS.md). It records the official source, archive and asset hashes, upstream paths, allowed output categories, transformations, official product names, terms URL, review date, and non-endorsement notice. +## Rendering with a local pack + +Use the imported directory explicitly when a diagram contains a namespaced provider icon: + +```sh +stack render architecture.stack \ + --provider-pack .stack-icons/aws \ + -o architecture.svg \ + --notice architecture.NOTICE.md +``` + +`--provider-pack` is repeatable for diagrams that use more than one provider. The renderer reads only `manifest.json` and its declared `assets/*.svg` regular files, rejects symbolic links and unsafe relative paths, caps each file at 1 MiB and each validated pack at 32 MiB, and performs no discovery, download, upload, or cache mutation. A provider icon changes only the visual asset; the authored node `kind` remains the source of semantic styling and layout behavior. + +`--notice` writes the exact pack revision, official archive hash, source release, terms URL, attribution, non-endorsement text, and used icon IDs for that rendered artifact. A missing pack or icon keeps the existing `STK5001` warning and provider-neutral fallback. Review the imported pack's `NOTICE.md` and linked terms before selecting it, and distribute the generated diagram and sidecar only as those terms permit. + ## Audited sources | Provider | Official source | Audited release | Complete archive SHA-256 | Terms and guidance | Imported IDs | diff --git a/src/lib.rs b/src/lib.rs index 4fc120a..113d4cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,11 @@ use std::ffi::{OsStr, OsString}; use std::fmt::Write as _; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use stack_engine::{ - CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, RenderOutput, Severity, + CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, ProviderAsset, ProviderNotice, + ProviderPack, RenderOutput, Severity, }; mod provider; @@ -21,13 +22,16 @@ pub const EXIT_STACK_ERROR: u8 = 1; /// Exit status used for argument, host I/O, or engine operational failures. pub const EXIT_USAGE_OR_IO: u8 = 2; -const GENERAL_HELP: &str = "Stack diagram toolchain\n\nUsage:\n stack check \n stack fmt [--check] \n stack render [-o ]\n stack icons import --accept-terms -o \n stack --help\n stack --version\n\nCommands:\n check Validate a Stack source file without modifying it\n fmt Format a file in place or read from standard input\n render Render standalone SVG to standard output or a file\n icons Import local provider icon archives\n"; +const GENERAL_HELP: &str = "Stack diagram toolchain\n\nUsage:\n stack check \n stack fmt [--check] \n stack render [--provider-pack ]... [-o ] [--notice ]\n stack icons import --accept-terms -o \n stack --help\n stack --version\n\nCommands:\n check Validate a Stack source file without modifying it\n fmt Format a file in place or read from standard input\n render Render standalone SVG to standard output or a file\n icons Import local provider icon archives\n"; const CHECK_HELP: &str = "Validate a Stack source file without modifying it\n\nUsage:\n stack check \n"; const FORMAT_HELP: &str = "Format Stack source canonically\n\nUsage:\n stack fmt \n stack fmt --check \n stack fmt -\n\nArguments:\n Format the file atomically in place\n - Read from standard input and write to standard output\n\nOptions:\n --check Report whether formatting is required without writing output\n"; -const RENDER_HELP: &str = "Render Stack source as standalone SVG\n\nUsage:\n stack render \n stack render -o \n\nArguments:\n Read Stack source bytes from this file\n\nOptions:\n -o Write SVG atomically instead of using standard output\n"; +const RENDER_HELP: &str = "Render Stack source as standalone SVG\n\nUsage:\n stack render [--provider-pack ]... [-o ] [--notice ]\n\nArguments:\n Read Stack source bytes from this file\n\nOptions:\n --provider-pack Load one local imported provider pack; repeatable\n -o Write SVG atomically instead of using standard output\n --notice Write exact used-provider notices atomically\n"; const ICONS_HELP: &str = "Manage local provider icon packs\n\nUsage:\n stack icons import --accept-terms -o \n\nProviders:\n aws AWS Architecture Icons release 2026-07-31\n gcp Google Cloud core product icons from the May 2026 guide\n azure Azure Public Service Icons V24\n"; const ICONS_IMPORT_HELP: &str = "Import an audited official provider icon archive locally\n\nUsage:\n stack icons import --accept-terms -o \n\nArguments:\n aws, gcp, or azure\n Local official ZIP archive; Stack performs no download or upload\n\nOptions:\n --accept-terms Confirm that you reviewed and accept the provider terms\n -o Create a new local pack directory atomically\n"; +const MAX_PROVIDER_MANIFEST_BYTES: usize = 1024 * 1024; +const MAX_PROVIDER_ASSET_BYTES: usize = 1024 * 1024; +const MAX_PROVIDER_PACKS: usize = 32; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FormatMode { @@ -233,32 +237,64 @@ fn run_render( ); } - let destination = match arguments.next() { - None => RenderDestination::Stdout, - Some(option) if option == OsStr::new("-o") => { + let mut destination = None; + let mut notice_path = None; + let mut provider_pack_paths = Vec::new(); + while let Some(option) = arguments.next() { + if option == OsStr::new("-o") { + if destination.is_some() { + return argument_error("duplicate '-o' option", stderr); + } let Some(output) = arguments.next() else { return argument_error("missing output file after '-o'", stderr); }; - if let Some(extra) = arguments.next() { - return argument_error( - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stderr, - ); + destination = Some(RenderDestination::File(PathBuf::from(output))); + } else if option == OsStr::new("--notice") { + if notice_path.is_some() { + return argument_error("duplicate '--notice' option", stderr); } - if output == source { - return argument_error("input and output files must be different", stderr); + let Some(path) = arguments.next() else { + return argument_error("missing notice file after '--notice'", stderr); + }; + notice_path = Some(PathBuf::from(path)); + } else if option == OsStr::new("--provider-pack") { + let Some(path) = arguments.next() else { + return argument_error("missing provider pack directory", stderr); + }; + provider_pack_paths.push(PathBuf::from(path)); + if provider_pack_paths.len() > MAX_PROVIDER_PACKS { + return argument_error("at most 32 provider packs may be loaded", stderr); } - RenderDestination::File(PathBuf::from(output)) - } - Some(extra) => { + } else { return argument_error( - &format!("unexpected argument '{}'", extra.to_string_lossy()), + &format!("unexpected argument '{}'", option.to_string_lossy()), stderr, ); } - }; + } - render_file(Path::new(&source), destination, stdout, stderr) + let destination = destination.unwrap_or(RenderDestination::Stdout); + if matches!(&destination, RenderDestination::File(path) if path.as_os_str() == source) { + return argument_error("input and output files must be different", stderr); + } + if notice_path + .as_ref() + .is_some_and(|path| path.as_os_str() == source) + { + return argument_error("input and notice files must be different", stderr); + } + if matches!(&destination, RenderDestination::File(path) if notice_path.as_ref() == Some(path)) { + return argument_error("output and notice files must be different", stderr); + } + + render_file( + Path::new(&source), + destination, + &provider_pack_paths, + notice_path.as_deref(), + stdout, + stderr, + ) } fn run_format( @@ -384,15 +420,40 @@ fn check_file_with( fn render_file( path: &Path, destination: RenderDestination, + provider_pack_paths: &[PathBuf], + notice_path: Option<&Path>, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let mut provider_packs = Vec::with_capacity(provider_pack_paths.len()); + for provider_pack_path in provider_pack_paths { + let provider_pack = match load_provider_pack(provider_pack_path) { + Ok(provider_pack) => provider_pack, + Err(reason) => { + return write_stderr_error( + &format!( + "cannot load provider pack '{}': {reason}", + provider_pack_path.display() + ), + stderr, + ); + } + }; + provider_packs.push(provider_pack); + } + let engine = match Engine::with_provider_packs(&provider_packs) { + Ok(engine) => engine, + Err(error) => { + return write_stderr_error(&format!("cannot load provider packs: {error}"), stderr); + } + }; render_file_with( path, destination, + notice_path, stdout, stderr, - |source| Engine::bundled().render(source), + |source| engine.render(source), atomic_write_output, ) } @@ -400,10 +461,11 @@ fn render_file( fn render_file_with( path: &Path, destination: RenderDestination, + notice_path: Option<&Path>, stdout: &mut dyn Write, stderr: &mut dyn Write, render: impl FnOnce(&[u8]) -> Result, - write_output: impl FnOnce(&Path, &[u8]) -> io::Result<()>, + mut write_output: impl FnMut(&Path, &[u8]) -> io::Result<()>, ) -> u8 { let source = match fs::read(path) { Ok(source) => source, @@ -457,9 +519,136 @@ fn render_file_with( } } } + if let Some(notice_path) = notice_path { + let notice = render_provider_notices(&output.provider_notices); + if let Err(error) = write_output(notice_path, notice.as_bytes()) { + return write_stderr_error( + &format!( + "cannot write '{}': {}", + notice_path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + } EXIT_SUCCESS } +fn load_provider_pack(root: &Path) -> Result { + let root_metadata = + fs::symlink_metadata(root).map_err(|error| stable_io_error(error.kind()).to_owned())?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("pack path must be a real directory, not a symlink".to_owned()); + } + + let manifest_path = root.join("manifest.json"); + let manifest_bytes = read_bounded_regular_file(&manifest_path, MAX_PROVIDER_MANIFEST_BYTES)?; + let manifest: stack_theme::ProviderPack = serde_json::from_slice(&manifest_bytes) + .map_err(|_| "manifest.json is invalid".to_owned())?; + let mut assets = Vec::with_capacity(manifest.icons.len()); + for icon in &manifest.icons { + let relative = safe_provider_asset_path(&icon.asset.path)?; + let path = root.join(relative); + let svg = read_bounded_regular_file(&path, MAX_PROVIDER_ASSET_BYTES)?; + let svg = + String::from_utf8(svg).map_err(|_| format!("'{}' is not UTF-8 SVG", path.display()))?; + assets.push(ProviderAsset::new(&icon.asset.path, svg)); + } + ProviderPack::new(manifest, assets).map_err(|error| error.to_string()) +} + +fn read_bounded_regular_file(path: &Path, limit: usize) -> Result, String> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "'{}' must be a regular file, not a symlink", + path.display() + )); + } + if metadata.len() > limit as u64 { + return Err(format!("'{}' exceeds the size limit", path.display())); + } + let mut contents = Vec::with_capacity(metadata.len() as usize); + File::open(path) + .and_then(|mut file| file.read_to_end(&mut contents)) + .map_err(|error| { + format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ) + })?; + if contents.len() > limit { + return Err(format!("'{}' exceeds the size limit", path.display())); + } + Ok(contents) +} + +fn safe_provider_asset_path(value: &str) -> Result<&Path, String> { + let path = Path::new(value); + let components = path.components().collect::>(); + if components.len() != 2 + || components[0] != Component::Normal(OsStr::new("assets")) + || !matches!(components[1], Component::Normal(_)) + || path.extension() != Some(OsStr::new("svg")) + { + return Err(format!("unsafe provider asset path '{value}'")); + } + Ok(path) +} + +fn render_provider_notices(notices: &[ProviderNotice]) -> String { + let mut output = String::from("# Stack provider icon notices\n"); + if notices.is_empty() { + output.push_str("\nNo provider icons were embedded in this artifact.\n"); + return output; + } + for notice in notices { + let _ = write!( + output, + "\n## {} (`{}`)\n\n- Pack version: {}\n- Pack revision: `{}`\n- Source release: {}\n- Archive SHA-256: `{}`\n- Terms URL: {}\n\n{}\n\n{}\n\n{}\n\nUsed icons:\n", + notice_text(¬ice.provider_name), + notice.provider_id, + notice_text(¬ice.pack_version), + notice.pack_revision, + notice_text(¬ice.source_release), + notice.archive_sha256, + notice_text(¬ice.terms_url), + notice_text(¬ice.attribution), + notice_text(¬ice.terms_summary), + notice_text(¬ice.non_endorsement), + ); + for icon in ¬ice.icons { + let _ = writeln!( + output, + "- `{}`: {}", + icon.id, + notice_text(&icon.product_name) + ); + } + } + output +} + +fn notice_text(value: &str) -> String { + value + .chars() + .map(|character| match character { + '\r' | '\n' | '\t' => ' ', + '<' | '>' => ' ', + _ if character.is_control() => ' ', + _ => character, + }) + .collect() +} + fn format_file(mode: FormatMode, path: &Path, stderr: &mut dyn Write) -> u8 { format_file_with( mode, @@ -855,7 +1044,47 @@ mod tests { OsString::from("render"), OsString::from("file.stack"), OsString::from("-o"), + OsString::from("one.svg"), + OsString::from("-o"), + OsString::from("two.svg"), + ], + vec![ + OsString::from("render"), OsString::from("file.stack"), + OsString::from("--notice"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("--notice"), + OsString::from("one.md"), + OsString::from("--notice"), + OsString::from("two.md"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("--provider-pack"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("-o"), + OsString::from("file.stack"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("--notice"), + OsString::from("file.stack"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("-o"), + OsString::from("artifact"), + OsString::from("--notice"), + OsString::from("artifact"), ], vec![OsString::from("icons")], vec![OsString::from("icons"), OsString::from("unknown")], @@ -907,8 +1136,21 @@ mod tests { assert!(String::from_utf8_lossy(&stderr).starts_with("error:")); } + let mut too_many_packs = vec![OsString::from("render"), OsString::from("file.stack")]; + for index in 0..=MAX_PROVIDER_PACKS { + too_many_packs.push(OsString::from("--provider-pack")); + too_many_packs.push(OsString::from(format!("pack-{index}"))); + } let mut stdout = Vec::new(); let mut stderr = Vec::new(); + assert_eq!( + run_without_input(too_many_packs, &mut stdout, &mut stderr), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stderr).contains("at most 32 provider packs")); + + stdout.clear(); + stderr.clear(); assert_eq!( run_without_input( [OsString::from("check"), OsString::from("-h")], @@ -1275,6 +1517,85 @@ mod tests { ); } + #[test] + fn provider_pack_loader_rejects_untrusted_host_inputs() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("provider-pack"); + let pack = load_provider_pack(&fixture); + assert_eq!( + pack.ok().map(|pack| pack.manifest().provider.id.clone()), + Some("example".to_owned()) + ); + + assert!(safe_provider_asset_path("assets/icon.svg").is_ok()); + for path in [ + "icon.svg", + "assets", + "assets/nested/icon.svg", + "assets/../icon.svg", + "/assets/icon.svg", + "assets/icon.png", + ] { + assert!(safe_provider_asset_path(path).is_err(), "accepted {path}"); + } + + let root = + std::env::temp_dir().join(format!("stack-cli-provider-loader-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + assert!(load_provider_pack(&root).is_err()); + assert!(fs::write(&root, b"not a directory").is_ok()); + assert!(load_provider_pack(&root).is_err()); + assert!(fs::remove_file(&root).is_ok()); + assert!(fs::create_dir_all(root.join("assets")).is_ok()); + + let manifest_path = root.join("manifest.json"); + assert!(fs::write(&manifest_path, b"{}").is_ok()); + assert!(load_provider_pack(&root).is_err()); + assert!( + fs::write( + &manifest_path, + include_bytes!("../tests/fixtures/provider-pack/manifest.json") + ) + .is_ok() + ); + let asset_path = root.join("assets/storage.svg"); + assert!(fs::write(&asset_path, [0xff]).is_ok()); + assert!(load_provider_pack(&root).is_err()); + assert!(fs::write(&asset_path, b"").is_ok()); + assert!(load_provider_pack(&root).is_err()); + + let oversized = root.join("oversized"); + assert!(fs::write(&oversized, vec![b'x'; 5]).is_ok()); + assert!(read_bounded_regular_file(&oversized, 4).is_err()); + assert!(read_bounded_regular_file(&root, 4).is_err()); + assert_eq!( + read_bounded_regular_file(&oversized, 5).ok(), + Some(vec![b'x'; 5]) + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let linked = root.join("linked"); + assert!(symlink(&oversized, &linked).is_ok()); + assert!(read_bounded_regular_file(&linked, 5).is_err()); + assert!(load_provider_pack(&linked).is_err()); + } + assert!(fs::remove_dir_all(root).is_ok()); + } + + #[test] + fn provider_notice_text_is_stable_and_inert() { + assert_eq!( + render_provider_notices(&[]), + "# Stack provider icon notices\n\nNo provider icons were embedded in this artifact.\n" + ); + assert_eq!(notice_text("line\n\ttext"), "line unsafe text"); + } + #[test] fn render_failures_do_not_emit_partial_artifacts() { let path = std::env::temp_dir().join(format!( @@ -1290,6 +1611,7 @@ mod tests { render_file_with( &path, RenderDestination::Stdout, + None, &mut stdout, &mut stderr, |_| { @@ -1315,6 +1637,7 @@ mod tests { render_file_with( &path, RenderDestination::Stdout, + None, &mut stdout, &mut stderr, |_| Ok(empty_output), @@ -1329,6 +1652,7 @@ mod tests { render_file_with( &path, RenderDestination::Stdout, + None, &mut failed_stdout, &mut stderr, |source| Engine::bundled().render(source), @@ -1343,6 +1667,7 @@ mod tests { render_file_with( &path, RenderDestination::File(output_path.clone()), + None, &mut stdout, &mut stderr, |source| Engine::bundled().render(source), @@ -1359,6 +1684,7 @@ mod tests { render_file_with( &path, RenderDestination::Stdout, + None, &mut stdout, &mut failed_stderr, |source| Engine::bundled().render(source), diff --git a/tests/fixtures/provider-pack/assets/storage.svg b/tests/fixtures/provider-pack/assets/storage.svg new file mode 100644 index 0000000..d4b6a8e --- /dev/null +++ b/tests/fixtures/provider-pack/assets/storage.svg @@ -0,0 +1 @@ + diff --git a/tests/fixtures/provider-pack/manifest.json b/tests/fixtures/provider-pack/manifest.json new file mode 100644 index 0000000..2e9d7b2 --- /dev/null +++ b/tests/fixtures/provider-pack/manifest.json @@ -0,0 +1,72 @@ +{ + "$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": [] + } + } + ] +} diff --git a/tests/render.rs b/tests/render.rs index 7f54efa..f9f8d48 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -51,6 +51,44 @@ fn engine_svg(source: &[u8]) -> Result> { .ok_or_else(|| format!("engine returned no SVG: {:?}", output.diagnostics).into()) } +#[test] +fn local_provider_pack_renders_namespaced_icon_and_exact_notice() -> Result<(), Box> { + let directory = TestDirectory::new("provider-pack")?; + let source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind queue icon \"example:storage\" } }"; + let input = directory.file("provider.stack", source)?; + let output_path = directory.path.join("provider.svg"); + let notice_path = directory.path.join("provider.NOTICE.md"); + let provider_pack = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("provider-pack"); + + let output = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("--provider-pack"), + provider_pack.as_os_str(), + OsStr::new("--notice"), + notice_path.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ])?; + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let svg = fs::read_to_string(output_path)?; + assert!(svg.contains("data-node-kind=\"queue\"")); + assert!(svg.contains("data-icon-id=\"example:storage\"")); + assert!(svg.contains("fill=\"#4285f4\"")); + let notice = fs::read_to_string(notice_path)?; + assert!(notice.contains("## Example Cloud (`example`)")); + assert!(notice.contains("https://example.com/terms")); + assert!(notice.contains("`example:storage`: Example Storage")); + assert!(notice.contains("Pack revision: `sha256:")); + Ok(()) +} + #[test] fn stdout_is_exactly_the_engine_svg() -> Result<(), Box> { let directory = TestDirectory::new("stdout")?; diff --git a/tests/specification-revision b/tests/specification-revision index 6da9620..25d3046 100644 --- a/tests/specification-revision +++ b/tests/specification-revision @@ -1 +1 @@ -f382069928c805fe69b7a192bfd6a877036bc036 +80df39151df67eedb4cfcb2de9f964129cd87466