diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af5a0e1..914e5a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,6 +66,8 @@ jobs: ./target/release/stack check --help ./target/release/stack fmt --help ./target/release/stack render --help + ./target/release/stack icons --help + ./target/release/stack icons import --help - name: Verify repository files run: | test -s README.md @@ -76,9 +78,11 @@ jobs: test -s CONTRIBUTING.md test -s SECURITY.md test -s docs/publication-audit.md + test -s docs/provider-icon-import.md test -s Cargo.toml test -s Cargo.lock test -s src/main.rs + test -s src/provider.rs test -s tests/specification-revision test -s tests/fixtures/render.stack diff --git a/AGENTS.md b/AGENTS.md index c2c27ff..f810f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,14 @@ ## Language -Write repository content, code comments, commit messages, issues, and pull requests in English even though this repository is private. +Write repository content, code comments, commit messages, issues, and pull requests in English. ## Architecture - Keep this repository focused on the native `stack` command, host I/O, exit codes, configuration discovery, provider-pack import, and notice output. - Link `stack-engine` as a native Rust dependency; do not duplicate compiler, formatter, layout, or SVG-rendering logic. - Keep provider-pack import, local cache behavior, provenance display, and notice output at the CLI boundary. Do not make the engine read the filesystem or network. +- Import only audited local archives with pinned complete-file hashes and allowlisted entries. Bound archive and entry sizes, reject unsafe paths and active SVG content, and write new pack directories atomically without overwriting existing data. - Do not add authentication, billing, entitlement, or proprietary-theme delivery behavior; they are outside the product roadmap. ## Licensing and security diff --git a/Cargo.lock b/Cargo.lock index 7a7d85f..da75173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,137 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[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 = "cfg-if" +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 = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[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 = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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" @@ -32,6 +157,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 = "serde" version = "1.0.229" @@ -59,7 +193,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -75,11 +209,27 @@ 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-cli" version = "0.1.0" dependencies = [ + "roxmltree", + "serde_json", + "sha2", "stack-engine", + "stack-theme", + "zip", ] [[package]] @@ -90,7 +240,7 @@ source = "git+https://github.com/stack-sh/compiler.git?rev=3d2379483da1edaeb24a2 [[package]] name = "stack-engine" version = "0.3.0" -source = "git+https://github.com/stack-sh/engine.git?rev=e1240661b6d8cebd95ef24207618a62fadb15b48#e1240661b6d8cebd95ef24207618a62fadb15b48" +source = "git+https://github.com/stack-sh/engine.git?rev=66a1d183a9b743990a4e0b7139fbe25b8bf5eb08#66a1d183a9b743990a4e0b7139fbe25b8bf5eb08" dependencies = [ "stack-compiler", "stack-formatter", @@ -100,20 +250,31 @@ dependencies = [ [[package]] name = "stack-formatter" version = "0.1.0" -source = "git+https://github.com/stack-sh/engine.git?rev=e1240661b6d8cebd95ef24207618a62fadb15b48#e1240661b6d8cebd95ef24207618a62fadb15b48" +source = "git+https://github.com/stack-sh/engine.git?rev=66a1d183a9b743990a4e0b7139fbe25b8bf5eb08#66a1d183a9b743990a4e0b7139fbe25b8bf5eb08" dependencies = [ "stack-compiler", ] [[package]] name = "stack-theme" -version = "0.2.0" -source = "git+https://github.com/stack-sh/theme.git?rev=d25b883884420adcc124e4c9c786ad92925eae60#d25b883884420adcc124e4c9c786ad92925eae60" +version = "0.3.0" +source = "git+https://github.com/stack-sh/theme.git?rev=5dbe41326370260cfc6b72d4aab4470318d66dab#5dbe41326370260cfc6b72d4aab4470318d66dab" dependencies = [ "serde", "serde_json", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.4" @@ -125,12 +286,37 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index ea29f40..624d57c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,12 @@ name = "stack" path = "src/main.rs" [dependencies] -stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "e1240661b6d8cebd95ef24207618a62fadb15b48" } +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-theme = { git = "https://github.com/stack-sh/theme.git", rev = "5dbe41326370260cfc6b72d4aab4470318d66dab" } +zip = { version = "=6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } [features] conformance = [] diff --git a/README.md b/README.md index 3a4b916..433ae73 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ stack fmt --check arch.stack 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 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. @@ -21,6 +22,8 @@ stack render arch.stack -o arch.svg `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 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. + | Result | Exit status | | --- | ---: | | No error diagnostics, including warning-only input | `0` | @@ -29,7 +32,7 @@ stack render arch.stack -o arch.svg 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 unless their exact terms permit software redistribution. Future provider-pack support will preserve upstream artwork and attach source and terms metadata. +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. ## Development diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 4273819..e8cfbda 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -6,9 +6,15 @@ Audit date: 2026-09-04 | Component | Version or revision | License | Source | Distribution note | | --- | --- | --- | --- | --- | -| `stack-engine` / `stack-formatter` | `e1240661b6d8cebd95ef24207618a62fadb15b48` | Apache-2.0 | | Linked into the native binary; includes the repository-authored core icon catalog through `stack-theme`. | +| `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-theme` | `d25b883884420adcc124e4c9c786ad92925eae60` | Apache-2.0 | | Linked transitively through `stack-engine`; its 30 fallback and 12 explicit core SVGs are Stack-authored Apache-2.0 assets. | +| `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. | +| `zip` | `6.0.0` | MIT | | Reads only audited, allowlisted entries from user-selected local ZIP archives. | +| `flate2` / `zlib-rs` / `crc32fast` | `1.1.10`, `0.6.7`, `1.5.1` | MIT OR Apache-2.0 / Zlib / MIT OR Apache-2.0 | , , | Pure Rust DEFLATE decoding and integrity checks for ZIP entries. | +| `indexmap` / `hashbrown` / `equivalent` | `2.14.1`, `0.17.1`, `1.0.2` | Apache-2.0 OR MIT | , , | ZIP archive entry index. | +| `cfg-if` / `cpufeatures` / `libc` | `1.0.4`, `0.3.1`, `0.2.189` | MIT OR Apache-2.0 | , , | Target selection and 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 | | Embedded catalog decoding through `stack-theme`. | | `itoa` | `1.0.18` | MIT OR Apache-2.0 | | Transitive runtime dependency of `serde_json`. | @@ -34,7 +40,8 @@ A future binary archive must include: - this repository's `LICENSE` and `NOTICE`; - this inventory at the dependency versions resolved in that release's `Cargo.lock`; - the Apache-2.0 text for Stack dependencies and dependencies distributed under the Apache-2.0 option; -- the complete MIT notices selected for `memchr`, `zmij`, and any dependency distributed under the MIT option; +- the complete MIT notices selected for `memchr`, `zmij`, `zip`, and any dependency distributed under the MIT option; +- the Zlib notice for `zlib-rs`; - any additional license text or attribution introduced by a later runtime dependency or provider pack. Build-only dependencies do not require inclusion in a binary archive when none of their source or object code is distributed, but they remain listed here so the audited build graph is reproducible. diff --git a/docs/provider-icon-import.md b/docs/provider-icon-import.md new file mode 100644 index 0000000..c101590 --- /dev/null +++ b/docs/provider-icon-import.md @@ -0,0 +1,56 @@ +# Local provider icon import + +## Boundary + +`stack icons import` converts a provider's official local ZIP archive into a Stack provider pack. Stack does not download, proxy, mirror, upload, or bundle provider asset bytes. The user obtains the archive from the official source, reviews the linked terms, and confirms that review with `--accept-terms`. + +The command rejects a changed archive rather than guessing new paths or terms. A new upstream release requires a reviewed code change that updates the source, complete archive SHA-256, entry allowlist, terms review, visual comparison, and fixtures. + +This guide records technical safeguards and provenance; it is not legal advice. Users remain responsible for applying the provider terms to their generated diagrams. + +## Usage + +```sh +stack icons import aws ~/Downloads/aws-icons.zip \ + --accept-terms \ + -o .stack-icons/aws +``` + +`PROVIDER` is `aws`, `gcp`, or `azure`. The output directory must not exist. A successful import creates: + +```text +/ + manifest.json + NOTICE.md + assets/ + .svg +``` + +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. + +## Audited sources + +| Provider | Official source | Audited release | Complete archive SHA-256 | Terms and guidance | Imported IDs | +| --- | --- | --- | --- | --- | --- | +| AWS | [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) | `Icon-package_07312026` | `d2d166c453526471749d520e0db022c459abef759d2946cf2dd1d1c992dc6526` | [AWS Trademark Guidelines](https://aws.amazon.com/trademark-guidelines/) | `aws:s3`, `aws:sqs`, `aws:lambda`, `aws:ec2`, `aws:rds`, `aws:dynamodb`, `aws:eks` | +| Google Cloud | [Google Cloud Icon Library](https://cloud.google.com/icons) | Core product icons from the May 2026 guide | `6531a10f58bc599c24d9a455d81dd757c1a03c3c43da9cddf639b859c1c1eece` | [Google Brand Resource Center](https://about.google/brand-resource-center/) | `gcp:cloud-run`, `gcp:cloud-storage`, `gcp:compute-engine`, `gcp:gke`, `gcp:bigquery`, `gcp:cloud-sql` | +| Azure | [Azure Architecture Icons](https://learn.microsoft.com/azure/architecture/icons/) | `Azure_Public_Service_Icons_V24` | `921594ccd1bf3d9c0a1bd7b6d924e050551a59342f2b353bb74bdcf761c35141` | `Microsoft_Terms_of_Use.pdf` inside the official archive and the source page | `azure:virtual-machines`, `azure:storage-accounts`, `azure:azure-sql-database`, `azure:aks`, `azure:app-service` | + +The Google Cloud category archive is separately audited but is not accepted by this first importer slice. In particular, `gcp:serverless` remains unavailable until the manifest contract can identify every source archive used by one pack. + +## Security and artwork preservation + +The importer: + +- caps the complete archive at 32 MiB and each selected SVG at 1 MiB; +- verifies the complete ZIP before parsing and reads only exact allowlisted entry names; +- rejects directories, symbolic links, traversal paths, malformed XML, document types, entities, processing instructions, nested SVG, scripts, event handlers, foreign namespaces, visible text, external URLs, data URLs, and executable URLs; +- accepts only a small SVG element and attribute allowlist; +- converts the audited Google Cloud fill classes into equivalent presentation attributes; +- removes comments, titles, unused identifiers, and non-rendering generator metadata; +- namespaces referenced Azure gradient IDs before embedding multiple icons in one document; +- preserves view boxes, geometry, colors, gradient stops, and aspect ratios; +- records original and processed hashes plus every visual-preservation transformation; +- creates the complete pack in a temporary sibling directory and renames it into place without overwriting an existing path. + +The audited 18 source/processed pairs were rasterized at 512 by 512 pixels and compared with zero changed pixels. Real provider archives and generated packs remain temporary local test inputs and are never committed. diff --git a/src/lib.rs b/src/lib.rs index 88cf40a..4fc120a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,8 @@ use stack_engine::{ CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, RenderOutput, Severity, }; +mod provider; + /// Exit status used when a command completes without Stack error diagnostics. pub const EXIT_SUCCESS: u8 = 0; /// Exit status used when Stack source contains at least one error diagnostic. @@ -19,11 +21,13 @@ 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 --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"; +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 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 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"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FormatMode { @@ -80,6 +84,9 @@ pub fn run( if command == OsStr::new("render") { return run_render(arguments, stdout, stderr); } + if command == OsStr::new("icons") { + return run_icons(arguments, stdout, stderr); + } argument_error( &format!("unknown command '{}'", command.to_string_lossy()), @@ -87,6 +94,121 @@ pub fn run( ) } +fn run_icons( + mut arguments: impl Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(command) = arguments.next() else { + return argument_error("missing command for 'stack icons'", stderr); + }; + if command == OsStr::new("--help") || command == OsStr::new("-h") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(ICONS_HELP, stdout, stderr); + } + if command != OsStr::new("import") { + return argument_error( + &format!( + "unknown command for 'stack icons': '{}'", + command.to_string_lossy() + ), + stderr, + ); + } + run_icons_import(arguments, stdout, stderr) +} + +fn run_icons_import( + mut arguments: impl Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(provider) = arguments.next() else { + return argument_error("missing provider for 'stack icons import'", stderr); + }; + if provider == OsStr::new("--help") || provider == OsStr::new("-h") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(ICONS_IMPORT_HELP, stdout, stderr); + } + if provider.to_string_lossy().starts_with('-') { + return argument_error( + &format!("unknown option '{}'", provider.to_string_lossy()), + stderr, + ); + } + let Some(archive) = arguments.next() else { + return argument_error("missing archive for 'stack icons import'", stderr); + }; + if archive.to_string_lossy().starts_with('-') { + return argument_error( + &format!("unknown option '{}'", archive.to_string_lossy()), + stderr, + ); + } + + let mut accepted_terms = false; + let mut output = None; + while let Some(option) = arguments.next() { + if option == OsStr::new("--accept-terms") { + if accepted_terms { + return argument_error("duplicate '--accept-terms' option", stderr); + } + accepted_terms = true; + } else if option == OsStr::new("-o") { + if output.is_some() { + return argument_error("duplicate '-o' option", stderr); + } + let Some(path) = arguments.next() else { + return argument_error("missing output directory after '-o'", stderr); + }; + output = Some(PathBuf::from(path)); + } else { + return argument_error( + &format!("unexpected argument '{}'", option.to_string_lossy()), + stderr, + ); + } + } + if !accepted_terms { + return argument_error( + "provider terms must be reviewed and accepted with '--accept-terms'", + stderr, + ); + } + let Some(output) = output else { + return argument_error("missing output directory after '-o'", stderr); + }; + if output == Path::new(&archive) { + return argument_error("archive and output directory must be different", stderr); + } + let provider_name = provider.to_string_lossy(); + match provider::import_provider_pack(&provider_name, Path::new(&archive), &output) { + Ok(summary) => write_stdout( + &format!( + "Imported {} {} icons to '{}'.\nManifest: {}\nNotice: {}\n", + summary.icon_count, + summary.provider_name, + output.display(), + summary.manifest_path.display(), + summary.notice_path.display() + ), + stdout, + stderr, + ), + Err(error) => write_stderr_error(&format!("cannot import provider icons: {error}"), stderr), + } +} + fn run_render( mut arguments: impl Iterator, stdout: &mut dyn Write, @@ -735,6 +857,40 @@ mod tests { OsString::from("-o"), OsString::from("file.stack"), ], + vec![OsString::from("icons")], + vec![OsString::from("icons"), OsString::from("unknown")], + vec![ + OsString::from("icons"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![OsString::from("icons"), OsString::from("import")], + vec![ + OsString::from("icons"), + OsString::from("import"), + OsString::from("aws"), + ], + vec![ + OsString::from("icons"), + OsString::from("import"), + OsString::from("aws"), + OsString::from("icons.zip"), + OsString::from("-o"), + OsString::from("pack"), + ], + vec![ + OsString::from("icons"), + OsString::from("import"), + OsString::from("aws"), + OsString::from("icons.zip"), + OsString::from("--accept-terms"), + OsString::from("-o"), + ], + vec![ + OsString::from("icons"), + OsString::from("import"), + OsString::from("--unknown"), + ], vec![ OsString::from("check"), OsString::from("file.stack"), @@ -785,6 +941,32 @@ mod tests { EXIT_SUCCESS ); assert_eq!(stdout, RENDER_HELP.as_bytes()); + + stdout.clear(); + assert_eq!( + run_without_input( + [OsString::from("icons"), OsString::from("--help")], + &mut stdout, + &mut stderr, + ), + EXIT_SUCCESS + ); + assert_eq!(stdout, ICONS_HELP.as_bytes()); + + stdout.clear(); + assert_eq!( + run_without_input( + [ + OsString::from("icons"), + OsString::from("import"), + OsString::from("--help"), + ], + &mut stdout, + &mut stderr, + ), + EXIT_SUCCESS + ); + assert_eq!(stdout, ICONS_IMPORT_HELP.as_bytes()); } #[test] diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..959352c --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,1713 @@ +//! Local-only provider icon archive import. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{Cursor, Read, Write}; +use std::path::{Path, PathBuf}; + +use roxmltree::{Document, Node, NodeType}; +use sha2::{Digest, Sha256}; +use stack_theme::{ + ProviderIcon, ProviderIconAsset, ProviderNodeKind, ProviderPack, ProviderPackDistributionMode, + ProviderPackIdentity, ProviderPackModificationPolicy, ProviderPackNotice, + ProviderPackPermittedOutput, ProviderPackProcessing, ProviderPackRedistribution, + ProviderPackRights, ProviderPackSource, ProviderPackTransformation, +}; +use zip::ZipArchive; + +const PROVIDER_PACK_SCHEMA: &str = + "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json"; +const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024; +const MAX_ICON_BYTES: u64 = 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", +]; + +#[derive(Clone, Copy)] +struct IconProfile<'a> { + slug: &'a str, + subject: &'a str, + product_name: &'a str, + node_kind: ProviderNodeKind, + archive_path: &'a str, +} + +#[derive(Clone, Copy)] +struct ProviderProfile<'a> { + id: &'a str, + name: &'a str, + page_url: &'a str, + archive_url: &'a str, + archive_sha256: &'a str, + release: &'a str, + retrieved_at: &'a str, + terms_url: &'a str, + terms_reviewed_at: &'a str, + review_after: &'a str, + copyright: &'a str, + license_id: &'a str, + archive_license_included: bool, + permitted_outputs: &'a [ProviderPackPermittedOutput], + product_name_nearby: bool, + attribution: &'a str, + terms_summary: &'a str, + non_endorsement: &'a str, + icons: &'a [IconProfile<'a>], +} + +/// Summary of one completed local provider-pack import. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ImportSummary { + pub(crate) provider_name: String, + pub(crate) icon_count: usize, + pub(crate) manifest_path: PathBuf, + pub(crate) notice_path: PathBuf, +} + +/// Stable local import error without archive-library implementation details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ImportError { + message: String, +} + +impl ImportError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for ImportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ImportError {} + +struct ProcessedIcon { + svg: String, + view_box: [i32; 4], + transformations: Vec, +} + +/// Imports one audited official archive into a new local pack directory. +pub(crate) fn import_provider_pack( + provider: &str, + archive_path: &Path, + output_path: &Path, +) -> Result { + let profile = match provider_profile(provider) { + Some(profile) => profile, + None => { + return Err(ImportError::new(format!( + "unknown provider '{provider}'; expected aws, gcp, or azure" + ))); + } + }; + import_profile(profile, archive_path, output_path) +} + +fn import_profile( + profile: ProviderProfile<'_>, + archive_path: &Path, + output_path: &Path, +) -> Result { + if output_path.exists() { + return Err(ImportError::new(format!( + "output '{}' already exists", + output_path.display() + ))); + } + let metadata = match fs::metadata(archive_path) { + Ok(metadata) => metadata, + Err(error) => { + return Err(ImportError::new(format!( + "cannot read archive '{}': {}", + archive_path.display(), + stable_io_error(error.kind()) + ))); + } + }; + if !metadata.is_file() { + return Err(ImportError::new(format!( + "archive '{}' is not a file", + archive_path.display() + ))); + } + if metadata.len() > MAX_ARCHIVE_BYTES { + return Err(ImportError::new(format!( + "archive '{}' exceeds the 32 MiB limit", + archive_path.display() + ))); + } + let archive_bytes = match fs::read(archive_path) { + Ok(bytes) => bytes, + Err(error) => { + return Err(ImportError::new(format!( + "cannot read archive '{}': {}", + archive_path.display(), + stable_io_error(error.kind()) + ))); + } + }; + let archive_digest = sha256(&archive_bytes); + if archive_digest != profile.archive_sha256 { + return Err(ImportError::new(format!( + "archive hash does not match the audited {} release; expected {}, received {}", + profile.release, profile.archive_sha256, archive_digest + ))); + } + + let mut archive = match ZipArchive::new(Cursor::new(archive_bytes)) { + Ok(archive) => archive, + Err(_) => return Err(ImportError::new("archive is not a supported ZIP file")), + }; + let mut manifest_icons = Vec::with_capacity(profile.icons.len()); + let mut processed_assets = Vec::with_capacity(profile.icons.len()); + + for icon in profile.icons { + let original = read_icon_entry(&mut archive, icon.archive_path)?; + let original_sha256 = sha256(&original); + let processed = sanitize_svg(&original, profile.id, icon.slug)?; + let processed_sha256 = sha256(processed.svg.as_bytes()); + let asset_path = format!("assets/{}.svg", icon.slug); + manifest_icons.push(ProviderIcon { + id: format!("{}:{}", profile.id, icon.slug), + subject: icon.subject.to_owned(), + product_name: icon.product_name.to_owned(), + recommended_node_kind: icon.node_kind, + asset: ProviderIconAsset { + path: asset_path.clone(), + original_path: icon.archive_path.to_owned(), + view_box: processed.view_box, + original_sha256, + processed_sha256, + transformations: processed.transformations, + }, + }); + processed_assets.push((asset_path, processed.svg)); + } + + let manifest = ProviderPack { + schema: PROVIDER_PACK_SCHEMA.to_owned(), + schema_version: "1.0".to_owned(), + pack_version: "0.1.0".to_owned(), + provider: ProviderPackIdentity { + id: profile.id.to_owned(), + name: profile.name.to_owned(), + }, + distribution_mode: ProviderPackDistributionMode::UserImported, + source: ProviderPackSource { + page_url: profile.page_url.to_owned(), + archive_url: profile.archive_url.to_owned(), + archive_sha256: profile.archive_sha256.to_owned(), + release: profile.release.to_owned(), + retrieved_at: profile.retrieved_at.to_owned(), + terms_url: profile.terms_url.to_owned(), + terms_reviewed_at: profile.terms_reviewed_at.to_owned(), + review_after: profile.review_after.to_owned(), + copyright: profile.copyright.to_owned(), + license_id: profile.license_id.to_owned(), + archive_license_included: profile.archive_license_included, + }, + rights: ProviderPackRights { + terms_acceptance_required: true, + permitted_outputs: profile.permitted_outputs.to_vec(), + redistribution: ProviderPackRedistribution { + cargo: false, + npm: false, + wasm: false, + web_asset: false, + native_binary: false, + generated_output: true, + }, + processing: ProviderPackProcessing { + local_only: true, + automatic_download: false, + server_upload: false, + preserve_colors: true, + preserve_geometry: true, + product_name_nearby: profile.product_name_nearby, + }, + modification_policy: ProviderPackModificationPolicy::VisualPreservationOnly, + }, + notice: ProviderPackNotice { + attribution: profile.attribution.to_owned(), + terms_summary: profile.terms_summary.to_owned(), + non_endorsement: profile.non_endorsement.to_owned(), + }, + icons: manifest_icons, + }; + let mut manifest_bytes = match serde_json::to_vec_pretty(&manifest) { + Ok(bytes) => bytes, + Err(_) => return Err(ImportError::new("cannot serialize provider manifest")), + }; + manifest_bytes.push(b'\n'); + let notice = render_notice(&manifest); + + let parent = match output_path.parent() { + Some(path) if !path.as_os_str().is_empty() => path, + _ => Path::new("."), + }; + if !parent.is_dir() { + return Err(ImportError::new(format!( + "output parent '{}' is not a directory", + parent.display() + ))); + } + let temporary_path = create_temporary_directory(parent)?; + let write_result = write_pack_directory( + &temporary_path, + &manifest_bytes, + notice.as_bytes(), + &processed_assets, + ); + if let Err(error) = write_result { + let _ = fs::remove_dir_all(&temporary_path); + return Err(error); + } + if let Err(error) = fs::rename(&temporary_path, output_path) { + let _ = fs::remove_dir_all(&temporary_path); + return Err(ImportError::new(format!( + "cannot create output '{}': {}", + output_path.display(), + stable_io_error(error.kind()) + ))); + } + + Ok(ImportSummary { + provider_name: profile.name.to_owned(), + icon_count: manifest.icons.len(), + manifest_path: output_path.join("manifest.json"), + notice_path: output_path.join("NOTICE.md"), + }) +} + +fn read_icon_entry( + archive: &mut ZipArchive, + entry_path: &str, +) -> Result, ImportError> { + let mut entry = match archive.by_name(entry_path) { + Ok(entry) => entry, + Err(_) => { + return Err(ImportError::new(format!( + "archive is missing required icon '{entry_path}'" + ))); + } + }; + if entry.is_dir() || entry.is_symlink() { + return Err(ImportError::new(format!( + "archive icon '{entry_path}' is not a regular file" + ))); + } + if entry.enclosed_name().as_deref() != Some(Path::new(entry_path)) { + return Err(ImportError::new(format!( + "archive icon '{entry_path}' has an unsafe path" + ))); + } + if entry.size() > MAX_ICON_BYTES { + return Err(ImportError::new(format!( + "archive icon '{entry_path}' exceeds the 1 MiB limit" + ))); + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + let read_result = entry + .by_ref() + .take(MAX_ICON_BYTES + 1) + .read_to_end(&mut bytes); + if read_result.is_err() { + return Err(ImportError::new(format!( + "cannot decode archive icon '{entry_path}'" + ))); + } + if bytes.len() as u64 > MAX_ICON_BYTES { + return Err(ImportError::new(format!( + "archive icon '{entry_path}' exceeds the 1 MiB limit" + ))); + } + Ok(bytes) +} + +fn sanitize_svg( + original: &[u8], + provider_id: &str, + icon_slug: &str, +) -> Result { + let source = match std::str::from_utf8(original) { + Ok(source) => source, + Err(_) => return Err(ImportError::new("provider icon is not valid UTF-8 SVG")), + }; + let source = strip_xml_declaration(source)?; + let uppercase = source.to_ascii_uppercase(); + if uppercase.contains(" document, + Err(_) => return Err(ImportError::new("provider icon is not well-formed SVG")), + }; + let root = document.root_element(); + if root.tag_name().name() != "svg" || root.tag_name().namespace() != Some(SVG_NAMESPACE) { + return Err(ImportError::new( + "provider icon root must use the canonical SVG namespace", + )); + } + let view_box_value = match root.attribute("viewBox") { + Some(value) => value, + None => return Err(ImportError::new("provider icon is missing viewBox")), + }; + let view_box = parse_view_box(view_box_value)?; + let styles = collect_styles(&document)?; + let (identifier_map, unused_identifiers) = identifier_map(&document, provider_id, icon_slug)?; + let mut removed_metadata = false; + for node in document.descendants() { + if matches!(node.node_type(), NodeType::Comment | NodeType::PI) + || (node.is_element() && matches!(node.tag_name().name(), "title" | "desc")) + { + removed_metadata = true; + break; + } + } + let mut state = SanitizeState { + styles: &styles, + identifiers: &identifier_map, + removed_metadata, + inlined_styles: !styles.is_empty(), + removed_unused_identifiers: unused_identifiers, + }; + let svg = match serialize_element(root, true, None, &mut state)? { + Some(svg) => svg, + None => { + return Err(ImportError::new( + "provider icon has no renderable SVG content", + )); + } + }; + let mut transformations = Vec::new(); + if state.removed_metadata { + transformations.push(ProviderPackTransformation::RemoveMetadata); + } + if state.inlined_styles { + transformations.push(ProviderPackTransformation::InlineStyles); + } + if state.removed_unused_identifiers { + transformations.push(ProviderPackTransformation::RemoveUnusedIdentifiers); + } + if !identifier_map.is_empty() { + transformations.push(ProviderPackTransformation::NamespaceIdentifiers); + } + transformations.push(ProviderPackTransformation::NormalizeXml); + + Ok(ProcessedIcon { + svg: format!("{svg}\n"), + view_box, + transformations, + }) +} + +fn strip_xml_declaration(source: &str) -> Result<&str, ImportError> { + let trimmed = source.trim_start_matches('\u{feff}').trim_start(); + let Some(after_prefix) = trimmed.strip_prefix("") { + Some(end) => end, + None => { + return Err(ImportError::new( + "provider icon has an incomplete XML declaration", + )); + } + }; + Ok(trimmed[end + 2..].trim_start()) +} + +fn parse_view_box(value: &str) -> Result<[i32; 4], ImportError> { + let mut values = Vec::with_capacity(4); + for component in + value.split(|character: char| character.is_ascii_whitespace() || character == ',') + { + if component.is_empty() { + continue; + } + match component.parse::() { + Ok(component) => values.push(component), + Err(_) => { + return Err(ImportError::new( + "provider icon viewBox must contain four integers", + )); + } + } + } + if values.len() != 4 || values[2] <= 0 || values[3] <= 0 { + return Err(ImportError::new( + "provider icon viewBox must contain four integers with positive dimensions", + )); + } + Ok([values[0], values[1], values[2], values[3]]) +} + +fn collect_styles(document: &Document<'_>) -> Result, ImportError> { + let mut styles = BTreeMap::new(); + for node in document.descendants() { + if !node.is_element() || node.tag_name().name() != "style" { + continue; + } + let stylesheet = match node.text() { + Some(stylesheet) => stylesheet, + None => return Err(ImportError::new("provider stylesheet is empty")), + }; + for block in stylesheet.split('}') { + let block = block.trim(); + if block.is_empty() { + continue; + } + let (selector, declarations) = match block.split_once('{') { + Some(parts) => parts, + None => { + return Err(ImportError::new("provider stylesheet has invalid syntax")); + } + }; + let class_name = match selector.trim().strip_prefix('.') { + Some(class_name) => class_name, + None => { + return Err(ImportError::new( + "provider stylesheet selector is not a class", + )); + } + }; + if class_name.is_empty() + || !class_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(ImportError::new( + "provider stylesheet has an invalid class name", + )); + } + let mut fill = None; + for declaration in declarations.split(';') { + let declaration = declaration.trim(); + if declaration.is_empty() { + continue; + } + let (property, value) = match declaration.split_once(':') { + Some(parts) => parts, + None => { + return Err(ImportError::new( + "provider stylesheet declaration is invalid", + )); + } + }; + if property.trim() != "fill" || fill.is_some() { + return Err(ImportError::new( + "provider stylesheet may define one fill property per class", + )); + } + validate_safe_value(value.trim())?; + fill = Some(value.trim().to_owned()); + } + let fill = match fill { + Some(fill) => fill, + None => { + return Err(ImportError::new( + "provider stylesheet class does not define fill", + )); + } + }; + if styles.insert(class_name.to_owned(), fill).is_some() { + return Err(ImportError::new( + "provider stylesheet declares a duplicate class", + )); + } + } + } + Ok(styles) +} + +fn identifier_map( + document: &Document<'_>, + provider_id: &str, + icon_slug: &str, +) -> Result<(BTreeMap, bool), ImportError> { + let mut declared = BTreeSet::new(); + let mut referenced = BTreeSet::new(); + for node in document.descendants() { + if !node.is_element() { + continue; + } + if let Some(identifier) = node.attribute("id") { + if !declared.insert(identifier.to_owned()) { + return Err(ImportError::new( + "provider icon declares a duplicate identifier", + )); + } + } + for attribute in node.attributes() { + if let Some(identifier) = local_reference(attribute.value()) { + referenced.insert(identifier.to_owned()); + } else if attribute.value().to_ascii_lowercase().contains("url(") { + return Err(ImportError::new( + "provider icon contains a non-local URL reference", + )); + } + } + } + for identifier in &referenced { + if !declared.contains(identifier) { + return Err(ImportError::new( + "provider icon references an undeclared identifier", + )); + } + } + let mut map = BTreeMap::new(); + for (index, identifier) in referenced.iter().enumerate() { + map.insert( + identifier.clone(), + format!("stack-{provider_id}-{icon_slug}-gradient-{index}"), + ); + } + let mut has_unused_identifier = false; + for identifier in &declared { + if !referenced.contains(identifier) { + has_unused_identifier = true; + break; + } + } + Ok((map, has_unused_identifier)) +} + +fn local_reference(value: &str) -> Option<&str> { + let value = value.strip_prefix("url(#")?; + let value = value.strip_suffix(')')?; + if value.is_empty() { None } else { Some(value) } +} + +struct SanitizeState<'a> { + styles: &'a BTreeMap, + identifiers: &'a BTreeMap, + removed_metadata: bool, + inlined_styles: bool, + removed_unused_identifiers: bool, +} + +fn serialize_element( + node: Node<'_, '_>, + is_root: bool, + parent_name: Option<&str>, + state: &mut SanitizeState<'_>, +) -> Result, ImportError> { + let name = node.tag_name().name(); + if matches!(name, "title" | "desc" | "style") { + return Ok(None); + } + if !ALLOWED_ELEMENTS.contains(&name) { + return Err(ImportError::new(format!( + "provider icon element '{name}' is not allowed" + ))); + } + if node.tag_name().namespace() != Some(SVG_NAMESPACE) { + return Err(ImportError::new( + "provider icon contains a foreign XML namespace", + )); + } + if name == "svg" && !is_root { + return Err(ImportError::new("nested SVG elements are not allowed")); + } + if name == "defs" && parent_name != Some("svg") { + return Err(ImportError::new("defs must be a direct child of SVG")); + } + if matches!(name, "linearGradient" | "radialGradient") && parent_name != Some("defs") { + return Err(ImportError::new( + "gradients must be direct children of defs", + )); + } + if name == "stop" && !matches!(parent_name, Some("linearGradient" | "radialGradient")) { + return Err(ImportError::new( + "gradient stops must be direct children of gradients", + )); + } + if parent_name == Some("defs") && !matches!(name, "linearGradient" | "radialGradient") { + return Err(ImportError::new("defs may contain only gradients")); + } + + let mut attributes = BTreeMap::new(); + for attribute in node.attributes() { + if attribute.namespace().is_some() { + return Err(ImportError::new( + "provider icon contains a namespaced attribute", + )); + } + let attribute_name = attribute.name(); + if attribute_name.starts_with("on") { + return Err(ImportError::new( + "provider icon contains an event handler attribute", + )); + } + match attribute_name { + "version" => continue, + "class" => { + let fill = match state.styles.get(attribute.value()) { + Some(fill) => fill, + None => { + return Err(ImportError::new( + "provider icon references an unknown stylesheet class", + )); + } + }; + attributes.insert("fill".to_owned(), fill.clone()); + continue; + } + "id" => { + if let Some(identifier) = state.identifiers.get(attribute.value()) { + if !matches!(name, "linearGradient" | "radialGradient") { + return Err(ImportError::new( + "only gradients may retain provider icon identifiers", + )); + } + attributes.insert("id".to_owned(), identifier.clone()); + } else { + state.removed_unused_identifiers = true; + } + continue; + } + _ => {} + } + if !ALLOWED_ATTRIBUTES.contains(&attribute_name) { + return Err(ImportError::new(format!( + "provider icon attribute '{attribute_name}' is not allowed" + ))); + } + let value = if let Some(identifier) = local_reference(attribute.value()) { + let mapped = match state.identifiers.get(identifier) { + Some(mapped) => mapped, + None => { + return Err(ImportError::new( + "provider icon references an undeclared identifier", + )); + } + }; + if !matches!(attribute_name, "fill" | "stroke") { + return Err(ImportError::new( + "local references are allowed only for fill or stroke", + )); + } + format!("url(#{mapped})") + } else { + validate_safe_value(attribute.value())?; + attribute.value().to_owned() + }; + if attributes + .insert(attribute_name.to_owned(), value) + .is_some() + { + return Err(ImportError::new( + "provider icon declares a duplicate effective attribute", + )); + } + } + + let mut children = String::new(); + for child in node.children() { + match child.node_type() { + NodeType::Element => { + if let Some(serialized) = serialize_element(child, false, Some(name), state)? { + children.push_str(&serialized); + } + } + NodeType::Text if child.text().unwrap_or_default().trim().is_empty() => {} + NodeType::Comment | NodeType::PI => state.removed_metadata = true, + _ => { + return Err(ImportError::new( + "provider icon contains unsupported visible text or XML content", + )); + } + } + } + if name == "defs" && children.is_empty() { + return Ok(None); + } + + let mut output = String::new(); + output.push('<'); + output.push_str(name); + if is_root { + output.push_str(" xmlns=\"http://www.w3.org/2000/svg\""); + } + for (name, value) in attributes { + output.push(' '); + output.push_str(&name); + output.push_str("=\""); + output.push_str(&escape_xml_attribute(&value)); + output.push('"'); + } + if children.is_empty() { + output.push_str("/>"); + } else { + output.push('>'); + output.push_str(&children); + output.push_str("'); + } + Ok(Some(output)) +} + +fn validate_safe_value(value: &str) -> Result<(), ImportError> { + let lowercase = value.to_ascii_lowercase(); + if lowercase.contains("url(") + || lowercase.contains("javascript:") + || lowercase.contains("data:") + || lowercase.contains("http://") + || lowercase.contains("https://") + || lowercase.contains("//") + { + return Err(ImportError::new( + "provider icon contains an external or executable reference", + )); + } + Ok(()) +} + +fn escape_xml_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn write_pack_directory( + root: &Path, + manifest: &[u8], + notice: &[u8], + assets: &[(String, String)], +) -> Result<(), ImportError> { + if fs::create_dir(root.join("assets")).is_err() { + return Err(ImportError::new( + "cannot create provider pack asset directory", + )); + } + write_new_file(&root.join("manifest.json"), manifest)?; + write_new_file(&root.join("NOTICE.md"), notice)?; + for (relative_path, svg) in assets { + write_new_file(&root.join(relative_path), svg.as_bytes())?; + } + Ok(()) +} + +fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), ImportError> { + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(_) => return Err(ImportError::new("cannot create provider pack file")), + }; + if file.write_all(contents).is_err() || file.sync_all().is_err() { + return Err(ImportError::new("cannot write provider pack file")); + } + Ok(()) +} + +fn create_temporary_directory(parent: &Path) -> Result { + for attempt in 0..128_u8 { + let path = parent.join(format!( + ".stack-provider-pack-{}-{attempt}", + std::process::id() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(ImportError::new(format!( + "cannot create temporary provider pack directory: {}", + stable_io_error(error.kind()) + ))); + } + } + } + Err(ImportError::new( + "cannot reserve a temporary provider pack directory", + )) +} + +fn render_notice(manifest: &ProviderPack) -> String { + let mut notice = format!( + "# Stack provider icon pack notice\n\nProvider: {} (`{}`)\n\nSource: <{}>\n\nOfficial archive: <{}>\n\nRelease: {}\n\nArchive SHA-256: `{}`\n\nTerms: <{}>\n\nTerms reviewed: {} (review again after {})\n\n{}\n\n{}\n\n{}\n\nThis local pack was created from an archive selected by the user. Stack does not redistribute these asset bytes. Use and distribute generated diagrams only as permitted by the linked provider terms.\n\n## Icons\n", + manifest.provider.name, + manifest.provider.id, + manifest.source.page_url, + manifest.source.archive_url, + manifest.source.release, + manifest.source.archive_sha256, + manifest.source.terms_url, + manifest.source.terms_reviewed_at, + manifest.source.review_after, + manifest.notice.attribution, + manifest.notice.terms_summary, + manifest.notice.non_endorsement, + ); + for icon in &manifest.icons { + notice.push_str(&format!("\n- `{}`: {}\n", icon.id, icon.product_name)); + } + notice +} + +fn sha256(bytes: &[u8]) -> String { + let mut output = String::from("sha256:"); + for byte in Sha256::digest(bytes) { + let _ = fmt::Write::write_fmt(&mut output, format_args!("{byte:02x}")); + } + output +} + +fn stable_io_error(kind: std::io::ErrorKind) -> &'static str { + match kind { + std::io::ErrorKind::NotFound => "file not found", + std::io::ErrorKind::PermissionDenied => "permission denied", + std::io::ErrorKind::InvalidData => "invalid data", + _ => "I/O error", + } +} + +fn provider_profile(provider: &str) -> Option> { + match provider { + "aws" => Some(AWS_PROFILE), + "gcp" => Some(GCP_PROFILE), + "azure" => Some(AZURE_PROFILE), + _ => None, + } +} + +const AWS_OUTPUTS: &[ProviderPackPermittedOutput] = &[ + ProviderPackPermittedOutput::ArchitectureDiagram, + ProviderPackPermittedOutput::Whitepaper, + ProviderPackPermittedOutput::Presentation, + ProviderPackPermittedOutput::DataSheet, + ProviderPackPermittedOutput::Poster, +]; + +const AWS_ICONS: &[IconProfile<'static>] = &[ + IconProfile { + slug: "s3", + subject: "Object storage service", + product_name: "Amazon Simple Storage Service (Amazon S3)", + node_kind: ProviderNodeKind::Storage, + archive_path: "Architecture-Service-Icons_07312026/Arch_Storage/48/Arch_Amazon-Simple-Storage-Service_48.svg", + }, + IconProfile { + slug: "sqs", + subject: "Managed message queue", + product_name: "Amazon Simple Queue Service (Amazon SQS)", + node_kind: ProviderNodeKind::Queue, + archive_path: "Architecture-Service-Icons_07312026/Arch_Application-Integration/48/Arch_Amazon-Simple-Queue-Service_48.svg", + }, + IconProfile { + slug: "lambda", + subject: "Serverless function service", + product_name: "AWS Lambda", + node_kind: ProviderNodeKind::Function, + archive_path: "Architecture-Service-Icons_07312026/Arch_Compute/48/Arch_AWS-Lambda_48.svg", + }, + IconProfile { + slug: "ec2", + subject: "Virtual compute service", + product_name: "Amazon Elastic Compute Cloud (Amazon EC2)", + node_kind: ProviderNodeKind::Service, + archive_path: "Architecture-Service-Icons_07312026/Arch_Compute/48/Arch_Amazon-EC2_48.svg", + }, + IconProfile { + slug: "rds", + subject: "Managed relational database service", + product_name: "Amazon Relational Database Service (Amazon RDS)", + node_kind: ProviderNodeKind::Database, + archive_path: "Architecture-Service-Icons_07312026/Arch_Databases/48/Arch_Amazon-RDS_48.svg", + }, + IconProfile { + slug: "dynamodb", + subject: "Managed NoSQL database service", + product_name: "Amazon DynamoDB", + node_kind: ProviderNodeKind::Database, + archive_path: "Architecture-Service-Icons_07312026/Arch_Databases/48/Arch_Amazon-DynamoDB_48.svg", + }, + IconProfile { + slug: "eks", + subject: "Managed Kubernetes service", + product_name: "Amazon Elastic Kubernetes Service (Amazon EKS)", + node_kind: ProviderNodeKind::Service, + archive_path: "Architecture-Service-Icons_07312026/Arch_Containers/48/Arch_Amazon-Elastic-Kubernetes-Service_48.svg", + }, +]; + +const AWS_PROFILE: ProviderProfile<'static> = ProviderProfile { + id: "aws", + name: "Amazon Web Services", + page_url: "https://aws.amazon.com/architecture/icons/", + archive_url: "https://d1.awsstatic.com/onedam/marketing-channels/website/public/shared/architecture-icon-release/Icon-package_07312026.5846e92413caa21490223536cc97f1269e44fa92.zip", + archive_sha256: "sha256:d2d166c453526471749d520e0db022c459abef759d2946cf2dd1d1c992dc6526", + release: "Icon-package_07312026", + retrieved_at: "2026-09-04", + terms_url: "https://aws.amazon.com/trademark-guidelines/", + terms_reviewed_at: "2026-09-04", + review_after: "2026-12-03", + copyright: "Copyright Amazon Web Services, Inc. or its affiliates", + license_id: "LicenseRef-AWS-Architecture-Icons-Terms", + archive_license_included: false, + permitted_outputs: AWS_OUTPUTS, + product_name_nearby: true, + attribution: "AWS architecture icons are owned by Amazon Web Services, Inc. or its affiliates.", + terms_summary: "Use is limited to the architecture-diagram materials described by the official AWS Architecture Icons page and applicable AWS trademark guidelines.", + non_endorsement: "Amazon Web Services does not sponsor or endorse this diagram or Stack.", + icons: AWS_ICONS, +}; + +const GCP_OUTPUTS: &[ProviderPackPermittedOutput] = &[ + ProviderPackPermittedOutput::ArchitectureDiagram, + ProviderPackPermittedOutput::Documentation, +]; + +const GCP_ICONS: &[IconProfile<'static>] = &[ + IconProfile { + slug: "cloud-run", + subject: "Managed application runtime", + product_name: "Cloud Run", + node_kind: ProviderNodeKind::Service, + archive_path: "Unique Icons/Cloud Run/SVG/CloudRun-512-color-rgb.svg", + }, + IconProfile { + slug: "cloud-storage", + subject: "Object storage service", + product_name: "Cloud Storage", + node_kind: ProviderNodeKind::Storage, + archive_path: "Unique Icons/Cloud Storage/SVG/Cloud_Storage-512-color.svg", + }, + IconProfile { + slug: "compute-engine", + subject: "Virtual compute service", + product_name: "Compute Engine", + node_kind: ProviderNodeKind::Service, + archive_path: "Unique Icons/Compute Engine/SVG/ComputeEngine-512-color-rgb.svg", + }, + IconProfile { + slug: "gke", + subject: "Managed Kubernetes service", + product_name: "Google Kubernetes Engine", + node_kind: ProviderNodeKind::Service, + archive_path: "Unique Icons/GKE/SVG/GKE-512-color.svg", + }, + IconProfile { + slug: "bigquery", + subject: "Managed analytics data warehouse", + product_name: "BigQuery", + node_kind: ProviderNodeKind::Database, + archive_path: "Unique Icons/BigQuery/SVG/BigQuery-512-color.svg", + }, + IconProfile { + slug: "cloud-sql", + subject: "Managed relational database service", + product_name: "Cloud SQL", + node_kind: ProviderNodeKind::Database, + archive_path: "Unique Icons/Cloud SQL/SVG/CloudSQL-512-color.svg", + }, +]; + +const GCP_PROFILE: ProviderProfile<'static> = ProviderProfile { + id: "gcp", + name: "Google Cloud", + page_url: "https://cloud.google.com/icons", + archive_url: "https://services.google.com/fh/files/misc/core-products-icons.zip", + archive_sha256: "sha256:6531a10f58bc599c24d9a455d81dd757c1a03c3c43da9cddf639b859c1c1eece", + release: "Core product icons (May 2026 guide)", + retrieved_at: "2026-09-04", + terms_url: "https://about.google/brand-resource-center/", + terms_reviewed_at: "2026-09-04", + review_after: "2026-12-03", + copyright: "Copyright Google LLC", + license_id: "LicenseRef-Google-Cloud-Product-Icons-Terms", + archive_license_included: false, + permitted_outputs: GCP_OUTPUTS, + product_name_nearby: true, + attribution: "Google Cloud product icons are owned by Google LLC.", + terms_summary: "Use is limited to diagrams and technical documentation described by the official Google Cloud Icon Library and applicable Google brand terms.", + non_endorsement: "Google does not sponsor or endorse this diagram or Stack.", + icons: GCP_ICONS, +}; + +const AZURE_OUTPUTS: &[ProviderPackPermittedOutput] = &[ + ProviderPackPermittedOutput::ArchitectureDiagram, + ProviderPackPermittedOutput::TrainingMaterial, + ProviderPackPermittedOutput::Documentation, +]; + +const AZURE_ICONS: &[IconProfile<'static>] = &[ + IconProfile { + slug: "virtual-machines", + subject: "Virtual compute service", + product_name: "Azure Virtual Machines", + node_kind: ProviderNodeKind::Service, + archive_path: "Azure_Public_Service_Icons/Icons/compute/10021-icon-service-Virtual-Machine.svg", + }, + IconProfile { + slug: "storage-accounts", + subject: "Cloud storage account", + product_name: "Azure Storage Accounts", + node_kind: ProviderNodeKind::Storage, + archive_path: "Azure_Public_Service_Icons/Icons/storage/10086-icon-service-Storage-Accounts.svg", + }, + IconProfile { + slug: "azure-sql-database", + subject: "Managed relational database service", + product_name: "Azure SQL Database", + node_kind: ProviderNodeKind::Database, + archive_path: "Azure_Public_Service_Icons/Icons/databases/10130-icon-service-SQL-Database.svg", + }, + IconProfile { + slug: "aks", + subject: "Managed Kubernetes service", + product_name: "Azure Kubernetes Service (AKS)", + node_kind: ProviderNodeKind::Service, + archive_path: "Azure_Public_Service_Icons/Icons/containers/10023-icon-service-Kubernetes-Services.svg", + }, + IconProfile { + slug: "app-service", + subject: "Managed application platform", + product_name: "Azure App Service", + node_kind: ProviderNodeKind::Service, + archive_path: "Azure_Public_Service_Icons/Icons/app services/10035-icon-service-App-Services.svg", + }, +]; + +const AZURE_PROFILE: ProviderProfile<'static> = ProviderProfile { + id: "azure", + name: "Microsoft Azure", + page_url: "https://learn.microsoft.com/azure/architecture/icons/", + archive_url: "https://arch-center.azureedge.net/icons/Azure_Public_Service_Icons_V24.zip", + archive_sha256: "sha256:921594ccd1bf3d9c0a1bd7b6d924e050551a59342f2b353bb74bdcf761c35141", + release: "Azure_Public_Service_Icons_V24", + retrieved_at: "2026-09-04", + terms_url: "https://learn.microsoft.com/azure/architecture/icons/", + terms_reviewed_at: "2026-09-04", + review_after: "2026-12-03", + copyright: "Copyright Microsoft Corporation", + license_id: "LicenseRef-Microsoft-Azure-Architecture-Icons-Terms", + archive_license_included: true, + permitted_outputs: AZURE_OUTPUTS, + product_name_nearby: true, + attribution: "Azure architecture icons are owned by Microsoft Corporation.", + terms_summary: "Use is limited to architecture diagrams, training materials, and documentation under the terms included in the official Azure icon archive.", + non_endorsement: "Microsoft does not sponsor or endorse this diagram or Stack.", + icons: AZURE_ICONS, +}; + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::SimpleFileOptions; + + const TEST_OUTPUTS: &[ProviderPackPermittedOutput] = + &[ProviderPackPermittedOutput::ArchitectureDiagram]; + + fn temporary_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!("stack-cli-provider-{label}-{}", std::process::id())) + } + + fn zip_with_entry(name: &str, contents: &[u8]) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + assert!( + writer + .start_file(name, SimpleFileOptions::default()) + .is_ok() + ); + assert!(writer.write_all(contents).is_ok()); + let result = writer.finish(); + assert!(result.is_ok()); + result.map(Cursor::into_inner).unwrap_or_default() + } + + fn zip_with_directory(name: &str) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + assert!( + writer + .add_directory(name, SimpleFileOptions::default()) + .is_ok() + ); + let result = writer.finish(); + assert!(result.is_ok()); + result.map(Cursor::into_inner).unwrap_or_default() + } + + fn assert_svg_error(source: &[u8], expected: &str) { + let error = sanitize_svg(source, "example", "fixture").err(); + assert!( + error.is_some_and(|error| error.to_string().contains(expected)), + "expected SVG error containing: {expected}" + ); + } + + fn read_test_icon(archive: Vec, entry_path: &str) -> Result, ImportError> { + let mut archive = match ZipArchive::new(Cursor::new(archive)) { + Ok(archive) => archive, + Err(_) => return Err(ImportError::new("invalid test archive")), + }; + read_icon_entry(&mut archive, entry_path) + } + + fn test_profile<'a>(archive_sha256: &'a str, archive_path: &'a str) -> ProviderProfile<'a> { + let icons = Box::leak(Box::new([IconProfile { + slug: "storage", + subject: "Object storage service", + product_name: "Example Storage", + node_kind: ProviderNodeKind::Storage, + archive_path, + }])); + ProviderProfile { + id: "example", + name: "Example Cloud", + page_url: "https://example.com/icons", + archive_url: "https://example.com/icons.zip", + archive_sha256, + release: "fixture-1", + retrieved_at: "2026-09-04", + terms_url: "https://example.com/terms", + terms_reviewed_at: "2026-09-04", + review_after: "2026-12-03", + copyright: "Copyright Example Cloud", + license_id: "LicenseRef-Example-Icons", + archive_license_included: false, + permitted_outputs: TEST_OUTPUTS, + product_name_nearby: true, + attribution: "Example Cloud owns the icons.", + terms_summary: "Architecture diagram use only.", + non_endorsement: "Example Cloud does not endorse Stack.", + icons, + } + } + + #[test] + fn audited_provider_profiles_have_expected_coverage() { + assert_eq!( + provider_profile("aws").map(|profile| profile.icons.len()), + Some(7) + ); + assert_eq!( + provider_profile("gcp").map(|profile| profile.icons.len()), + Some(6) + ); + assert_eq!( + provider_profile("azure").map(|profile| profile.icons.len()), + Some(5) + ); + assert!(provider_profile("unknown").is_none()); + } + + #[test] + fn sanitizer_preserves_colors_and_namespaces_gradients() { + let source = br##"Storage"##; + let processed = sanitize_svg(source, "azure", "storage-accounts"); + assert!(processed.is_ok()); + let Ok(processed) = processed else { + return; + }; + assert_eq!(processed.view_box, [0, 0, 18, 18]); + assert!(processed.svg.contains("#37c2b1")); + assert!(processed.svg.contains("#258277")); + assert!( + processed + .svg + .contains("id=\"stack-azure-storage-accounts-gradient-0\"") + ); + assert!( + processed + .svg + .contains("fill=\"url(#stack-azure-storage-accounts-gradient-0)\"") + ); + assert!(!processed.svg.contains(""##; + let processed = sanitize_svg(source, "gcp", "cloud-run"); + assert!(processed.is_ok()); + let Ok(processed) = processed else { + return; + }; + assert!(processed.svg.contains("fill=\"none\"")); + assert!(processed.svg.contains("fill=\"#4285f4\"")); + assert!(!processed.svg.contains("style")); + assert!(!processed.svg.contains("class")); + assert!( + processed + .transformations + .contains(&ProviderPackTransformation::InlineStyles) + ); + } + + #[test] + fn sanitizer_rejects_active_or_external_svg_content() { + for source in [ + br#""#.as_slice(), + br#""#.as_slice(), + br#""#.as_slice(), + br#""#.as_slice(), + ] { + assert!(sanitize_svg(source, "example", "unsafe").is_err()); + } + } + + #[test] + fn sanitizer_rejects_invalid_xml_roots_and_view_boxes() { + for (source, expected) in [ + (b"\xff".as_slice(), "not valid UTF-8"), + ( + b""#.as_slice(), + "canonical SVG namespace", + ), + ( + br#""#.as_slice(), + "canonical SVG namespace", + ), + ( + br#""#.as_slice(), + "missing viewBox", + ), + ( + br#""#.as_slice(), + "forbidden XML declaration", + ), + ( + br#""#.as_slice(), + "four integers", + ), + ( + br#""#.as_slice(), + "positive dimensions", + ), + ( + br#""#.as_slice(), + "positive dimensions", + ), + ] { + assert_svg_error(source, expected); + } + } + + #[test] + fn sanitizer_rejects_unsupported_stylesheets() { + for (style, expected) in [ + ("", "stylesheet is empty"), + ("bad", "invalid syntax"), + ("path{fill:red}", "selector is not a class"), + (".!{fill:red}", "invalid class name"), + (".a{fill}", "declaration is invalid"), + (".a{stroke:red}", "one fill property"), + (".a{fill:red;fill:blue}", "one fill property"), + (".a{}", "does not define fill"), + (".a{fill:red}.a{fill:blue}", "duplicate class"), + (".a{fill:javascript:alert(1)}", "external or executable"), + ] { + let source = format!( + "" + ); + assert_svg_error(source.as_bytes(), expected); + } + assert_svg_error( + format!( + "" + ) + .as_bytes(), + "stylesheet is empty", + ); + } + + #[test] + fn sanitizer_rejects_unsafe_structure_attributes_and_identifiers() { + for (body, expected) in [ + ( + "", + "duplicate identifier", + ), + ( + "", + "undeclared identifier", + ), + ( + "", + "foreign XML namespace", + ), + ("", "nested SVG"), + ("", "defs must be a direct child"), + ("", "gradients must be direct children"), + ("", "gradient stops must be direct children"), + ( + "", + "defs may contain only gradients", + ), + ("", "namespaced attribute"), + ( + "", + "unknown stylesheet class", + ), + ( + "", + "only gradients may retain", + ), + ( + "", + "attribute 'unknown' is not allowed", + ), + ( + "", + "only for fill or stroke", + ), + ("visible", "unsupported visible text"), + ( + "", + "external or executable", + ), + ] { + let source = + format!("{body}"); + assert_svg_error(source.as_bytes(), expected); + } + + let duplicate_fill = format!( + "" + ); + assert_svg_error( + &duplicate_fill.into_bytes(), + "duplicate effective attribute", + ); + } + + #[test] + fn sanitizer_removes_empty_definitions_and_escapes_attributes() { + let source = format!( + "metadata" + ); + let processed = sanitize_svg(source.as_bytes(), "example", "escape"); + assert!(processed.is_ok()); + let Some(processed) = processed.ok() else { + return; + }; + assert!(!processed.svg.contains("").as_bytes(), + ); + assert!(fs::write(&valid_archive_path, &valid_archive).is_ok()); + let parent_file = root.join("not-a-directory"); + assert!(fs::write(&parent_file, b"file").is_ok()); + assert!( + import_profile( + test_profile(&sha256(&valid_archive), "icon.svg"), + &valid_archive_path, + &parent_file.join("pack"), + ) + .err() + .is_some_and(|error| error.to_string().contains("not a directory")) + ); + assert!(fs::remove_dir_all(&root).is_ok()); + } + + #[test] + fn pack_writes_and_temporary_directories_fail_closed() { + let root = temporary_root("write-failures"); + let _ = fs::remove_dir_all(&root); + assert!(fs::create_dir(&root).is_ok()); + + assert!(fs::create_dir(root.join("assets")).is_ok()); + assert!( + write_pack_directory(&root, b"{}", b"notice", &[]) + .err() + .is_some_and(|error| error.to_string().contains("asset directory")) + ); + let existing = root.join("existing"); + assert!(fs::write(&existing, b"existing").is_ok()); + assert!( + write_new_file(&existing, b"replacement") + .err() + .is_some_and(|error| error.to_string().contains("cannot create")) + ); + assert!( + create_temporary_directory(&root.join("missing")) + .err() + .is_some_and(|error| error.to_string().contains("cannot create temporary")) + ); + + let collision_root = root.join("collisions"); + assert!(fs::create_dir(&collision_root).is_ok()); + for attempt in 0..128_u8 { + assert!( + fs::create_dir(collision_root.join(format!( + ".stack-provider-pack-{}-{attempt}", + std::process::id() + ))) + .is_ok() + ); + } + assert!( + create_temporary_directory(&collision_root) + .err() + .is_some_and(|error| error.to_string().contains("cannot reserve")) + ); + assert_eq!( + stable_io_error(std::io::ErrorKind::PermissionDenied), + "permission denied" + ); + assert_eq!( + stable_io_error(std::io::ErrorKind::InvalidData), + "invalid data" + ); + assert_eq!(stable_io_error(std::io::ErrorKind::Other), "I/O error"); + assert!(fs::remove_dir_all(&root).is_ok()); + } + + #[test] + fn local_import_writes_manifest_notice_and_sanitized_asset_atomically() { + let root = temporary_root("success"); + let archive_path = root.with_extension("zip"); + let output_path = root.join("pack"); + let svg = br##""##; + let archive = zip_with_entry("icons/storage.svg", svg); + assert!(fs::write(&archive_path, &archive).is_ok()); + assert!(fs::create_dir(&root).is_ok()); + let digest = sha256(&archive); + let imported = import_profile( + test_profile(&digest, "icons/storage.svg"), + &archive_path, + &output_path, + ); + assert!(imported.is_ok()); + let Ok(imported) = imported else { + return; + }; + assert_eq!(imported.icon_count, 1); + let manifest = fs::read_to_string(&imported.manifest_path); + assert!(manifest.is_ok()); + let Some(manifest): Option = manifest + .ok() + .and_then(|value| serde_json::from_str(&value).ok()) + else { + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_file(&archive_path); + return; + }; + assert_eq!(manifest.provider.id, "example"); + assert_eq!(manifest.icons[0].id, "example:storage"); + assert_eq!(manifest.icons[0].asset.original_sha256, sha256(svg)); + assert!(output_path.join("assets/storage.svg").is_file()); + assert!( + fs::read_to_string(imported.notice_path) + .unwrap_or_default() + .contains("Stack does not redistribute these asset bytes") + ); + assert!(fs::remove_dir_all(&root).is_ok()); + assert!(fs::remove_file(&archive_path).is_ok()); + } + + #[test] + fn local_import_rejects_hash_mismatch_existing_output_and_unsafe_paths() { + let root = temporary_root("failures"); + let archive_path = root.with_extension("zip"); + let output_path = root.join("pack"); + let archive = zip_with_entry("../unsafe.svg", b"not svg"); + assert!(fs::write(&archive_path, &archive).is_ok()); + assert!(fs::create_dir(&root).is_ok()); + + let mismatch = import_profile( + test_profile( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "../unsafe.svg", + ), + &archive_path, + &output_path, + ); + assert!( + mismatch + .err() + .is_some_and(|error| error.to_string().contains("archive hash does not match")) + ); + + let digest = sha256(&archive); + let unsafe_path = import_profile( + test_profile(&digest, "../unsafe.svg"), + &archive_path, + &output_path, + ); + assert!( + unsafe_path + .err() + .is_some_and(|error| error.to_string().contains("unsafe path")) + ); + + assert!(fs::create_dir(&output_path).is_ok()); + let existing = import_profile( + test_profile(&digest, "../unsafe.svg"), + &archive_path, + &output_path, + ); + assert!( + existing + .err() + .is_some_and(|error| error.to_string().contains("already exists")) + ); + assert!(fs::remove_dir_all(&root).is_ok()); + assert!(fs::remove_file(&archive_path).is_ok()); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 3fc803e..3e1e153 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -143,6 +143,11 @@ fn help_and_version_are_stdout_only() -> Result<(), Box> { assert!(help.stderr.is_empty()); assert!(String::from_utf8(help.stdout)?.contains("stack check ")); + let icons_help = stack(["icons", "--help"])?; + assert_eq!(icons_help.status.code(), Some(0)); + assert!(icons_help.stderr.is_empty()); + assert!(String::from_utf8(icons_help.stdout)?.contains("AWS Architecture Icons")); + let version = stack(["--version"])?; assert_eq!(version.status.code(), Some(0)); assert_eq!(version.stdout, b"stack 0.1.0\n");