From 368f927b901a11b402efc9dd7f376ab61df28ef1 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 03:27:11 +0900 Subject: [PATCH] Add shared provider icon store --- .github/workflows/ci.yaml | 1 + Cargo.lock | 297 ++++++++++++++++++++++++++++++++- Cargo.toml | 4 +- README.md | 11 +- THIRD_PARTY_LICENSES.md | 20 ++- docs/provider-icon-import.md | 162 ++++++++---------- src/config.rs | 315 +++++++++++++++++++++++++++++++++++ src/lib.rs | 303 ++++++++++++++++++++++++--------- src/provider.rs | 266 ++++++++++++++++++++++++++--- src/provider_catalog.rs | 7 +- tests/cli.rs | 10 +- tests/render.rs | 81 +++++++-- 12 files changed, 1258 insertions(+), 219 deletions(-) create mode 100644 src/config.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1db577e..8c91e4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,6 +85,7 @@ jobs: test -s docs/provider-icon-import.md test -s Cargo.toml test -s Cargo.lock + test -s src/config.rs test -s src/main.rs test -s src/provider.rs test -s src/provider_catalog.rs diff --git a/Cargo.lock b/Cargo.lock index 145caeb..bbe151f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "block-buffer" version = "0.12.1" @@ -20,6 +26,22 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -87,6 +109,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + [[package]] name = "flate2" version = "1.1.10" @@ -96,12 +124,39 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.14" @@ -133,12 +188,30 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -157,6 +230,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -166,6 +253,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -209,6 +337,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.11.0" @@ -220,16 +361,24 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "stack-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "roxmltree", "serde", "serde_json", + "serde_yaml_ng", "sha2", "stack-engine", "stack-theme", + "ureq", "zip", ] @@ -268,6 +417,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -302,6 +457,146 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zip" version = "6.0.0" diff --git a/Cargo.toml b/Cargo.toml index 177d128..0613b96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-cli" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.85" publish = false @@ -17,9 +17,11 @@ path = "src/main.rs" roxmltree = "=0.21.1" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" +serde_yaml_ng = "=0.10.0" sha2 = "=0.11.0" stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "2c9adabd2ffee627ac5fe3ee26331b0ad75bceac" } stack-theme = { git = "https://github.com/stack-sh/theme.git", rev = "7e208d6a3c90d255799f390a4e8b86248c73caee" } +ureq = { version = "=3.4.0", default-features = false, features = ["rustls"] } zip = { version = "=6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } [features] diff --git a/README.md b/README.md index cffcfea..2d7b461 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,20 @@ stack render arch.stack stack render arch.stack -o arch.svg stack icons list stack icons list aws s3 -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 icons import gcp --accept-terms +stack icons import simple-icons --accept-terms +stack render arch.stack -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. 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 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. It discovers imported `aws`, `gcp`, `azure`, and `simple-icons` packs below the shared icon store. Use `--provider-pack ` for a project-local icon-store root, 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. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. `stack icons list [PROVIDER] [QUERY]` searches the asset-free catalog by ID, product name, or category. The catalog currently contains 1,051 IDs: 305 AWS, 45 Google Cloud, 639 Azure, and 62 curated developer and collaboration tool icons. This command reads only metadata embedded in the CLI. -`stack icons import` creates a local provider pack from official ZIP archives that the user selected. It performs no network request or upload, requires explicit terms acceptance, verifies every complete archive against an audited SHA-256, reads only allowlisted SVG entries with fixed size limits, removes active or external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically to a new directory. Google Cloud combines its core and category archives with `--source categories=`. Simple Icons entries also retain the recorded rights-owner source and brand-guideline links because that project's CC0 distribution does not grant rights to every underlying brand. See [the provider icon import guide](./docs/provider-icon-import.md) for sources, hashes, usage, and rights boundaries. +`stack icons import --accept-terms` downloads the audited official archive set, verifies every complete SHA-256 before ZIP processing, reads allowlisted SVG entries with fixed size limits, sanitizes active and external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically. The default store is `$XDG_CONFIG_HOME/stack/icons`, falling back to `$HOME/.config/stack/icons`. `$XDG_CONFIG_HOME/stack/config.yaml` can set an absolute `default_icons_path`. Use `-o ` to put provider child directories below a project-local root. See [the provider icon guide](./docs/provider-icon-import.md) for configuration, project-local usage, sources, hashes, and rights. | Result | Exit status | | --- | ---: | @@ -37,7 +38,7 @@ stack render arch.stack --provider-pack .stack-icons/aws -o arch.svg --notice ar 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 30 provider-neutral core icons: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. 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. +The bundled engine resolves 30 provider-neutral core icons: `api`, `web`, `mobile`, `desktop`, `server`, `container`, `cluster`, `cloud`, `scheduler`, `webhook`, `identity`, `observability`, `gateway`, `load-balancer`, `dns`, `cdn`, `firewall`, `network`, `event`, `stream`, `search`, `analytics`, `repository`, `pipeline`, `secret`, `document`, `task`, `chat`, `email`, and `ai`. User-managed provider packs preserve upstream artwork and attach source, archive hash, transformation, terms, and notice metadata. Rendering resolves namespaced IDs such as `aws:s3`, preserves the authored semantic `kind`, embeds 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 9857c1c..52e435d 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,17 +1,17 @@ # Third-party licenses -Audit date: 2026-09-04 +Audit date: 2026-09-05 ## Runtime dependencies | Component | Version or revision | License | Source | Distribution note | | --- | --- | --- | --- | --- | -| `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-engine` / `stack-formatter` | `2c9adabd2ffee627ac5fe3ee26331b0ad75bceac` | 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. | +| `stack-theme` | `7e208d6a3c90d255799f390a4e8b86248c73caee` | 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. | +| `zip` | `6.0.0` | MIT | | Reads audited, allowlisted entries from verified official 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. | @@ -20,6 +20,16 @@ Audit date: 2026-09-04 | `itoa` | `1.0.18` | MIT OR Apache-2.0 | | Transitive runtime dependency of `serde_json`. | | `memchr` | `2.8.3` | Unlicense OR MIT | | Transitive runtime dependency of `serde_json`. | | `zmij` | `1.0.23` | MIT | | Transitive runtime dependency of `serde_json`. | +| `serde_yaml_ng` / `unsafe-libyaml` / `ryu` | `0.10.0`, `0.2.11`, `1.0.23` | MIT / MIT / Apache-2.0 OR BSL-1.0 | , , | Parses the bounded user configuration file. | +| `ureq` / `ureq-proto` / `utf8-zero` | `3.4.0`, `0.6.1`, `0.8.1` | MIT OR Apache-2.0 | , , | Downloads audited provider archives over HTTPS with bounded response bodies. | +| `rustls` / `rustls-pki-types` / `rustls-webpki` | `0.23.43`, `1.15.1`, `0.103.15` | Apache-2.0 OR ISC OR MIT / MIT OR Apache-2.0 / ISC | , , | TLS implementation and certificate validation for provider archive downloads. | +| `ring` / `untrusted` | `0.17.14`, `0.9.0` | Apache-2.0 AND ISC / ISC | , | Cryptography and bounded certificate parsing through `rustls`. | +| `webpki-roots` | `1.0.9` | CDLA-Permissive-2.0 | | Mozilla-derived trust anchors used by the Rustls connector. | +| `base64` / `percent-encoding` / `http` / `bytes` / `httparse` | `0.23.1`, `2.3.2`, `1.5.0`, `1.12.1`, `1.10.1` | MIT OR Apache-2.0 / MIT OR Apache-2.0 / MIT OR Apache-2.0 / MIT / MIT OR Apache-2.0 | , , , , | HTTP request, response, and URL representation through `ureq`. | +| `log` / `once_cell` | `0.4.34`, `1.21.4` | MIT OR Apache-2.0 | , | Runtime support through the HTTP and TLS stack. | +| `getrandom` / `subtle` / `zeroize` | `0.2.17`, `2.6.1`, `1.9.0` | MIT OR Apache-2.0 / BSD-3-Clause / Apache-2.0 OR MIT | , , | Randomness and cryptographic value handling through `ring` and `rustls`. | +| `wasi` | `0.11.1+wasi-snapshot-preview1` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | | Target-specific randomness bindings. | +| `windows-sys`, `windows-targets`, and target architecture crates | `0.52.0`, `0.52.6` | MIT OR Apache-2.0 | | Target-specific Windows runtime bindings. | ## Build-only dependencies @@ -30,6 +40,7 @@ Audit date: 2026-09-04 | `quote` | `1.0.47` | MIT OR Apache-2.0 | | Procedural-macro build dependency; not linked into the release binary. | | `syn` | `3.0.4` | MIT OR Apache-2.0 | | Procedural-macro build dependency; not linked into the release binary. | | `unicode-ident` | `1.0.24` | (MIT OR Apache-2.0) AND Unicode-3.0 | | Procedural-macro build dependency; not linked into the release binary. | +| `cc` / `find-msvc-tools` / `shlex` | `1.4.5`, `0.1.12`, `2.0.1` | MIT OR Apache-2.0 | | Build dependencies of `ring`; not linked into the release binary. | No third-party vendor icon is bundled in the repository or binary. Provider-specific assets require a separate rights record covering source revision, copyright, trademark restrictions, modification, software redistribution, commercial diagram output, and required notices. @@ -42,6 +53,7 @@ A future binary archive must include: - 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`, `zip`, and any dependency distributed under the MIT option; - the Zlib notice for `zlib-rs`; +- the ISC, BSD-3-Clause, CDLA-Permissive-2.0, BSL-1.0, and other applicable notices selected for the resolved networking and TLS dependencies; - 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 index c834b9b..dd9bb3a 100644 --- a/docs/provider-icon-import.md +++ b/docs/provider-icon-import.md @@ -1,149 +1,123 @@ -# Local provider icon import +# Provider icons -## Boundary +`stack icons import` downloads the official archives recorded in the CLI catalog, verifies their complete SHA-256 hashes, sanitizes the selected SVGs, and creates provider packs in the user icon store. `--accept-terms` records the user's confirmation that they reviewed the linked provider and brand terms. -`stack icons import` converts official local ZIP archives into a Stack provider pack. Stack does not download, proxy, mirror, upload, or bundle provider asset bytes. The user obtains every archive from the recorded source, reviews the linked provider and brand terms, and confirms that review with `--accept-terms`. +## Quick start -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. +Import every provider used by a diagram once: -This guide records technical safeguards and provenance; it is not legal advice. Users remain responsible for applying the provider terms to their generated diagrams. - -## Download the audited archives +```sh +$ stack icons import gcp --accept-terms +$ stack icons import simple-icons --accept-terms +$ stack render architecture.stack -o architecture.svg +``` -These commands download the exact official archives audited by the current CLI catalog. `curl` only retrieves the files; `stack icons import` independently verifies the complete SHA-256 before processing them. If a provider publishes a newer archive, use the official source page to review it, but do not substitute it here until the Stack catalog has been updated. +The same render command works for one or several imported providers. Namespaced icon IDs identify the pack, such as `gcp:cloud-run` and `simple-icons:github`. -### AWS +## Icon store -Source: [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) +The default icon store is: -```sh -$ curl -fL "https://d1.awsstatic.com/onedam/marketing-channels/website/public/shared/architecture-icon-release/Icon-package_07312026.5846e92413caa21490223536cc97f1269e44fa92.zip" -o aws-icons.zip -$ stack icons import aws ./aws-icons.zip --accept-terms -o .stack-icons/aws +```text +$XDG_CONFIG_HOME/stack/icons ``` -### Google Cloud +When `XDG_CONFIG_HOME` is unset, Stack uses: -Source: [Google Cloud Icon Library](https://cloud.google.com/icons). Google publishes the required core-product and category icons separately. +```text +$HOME/.config/stack/icons +``` -```sh -$ curl -fL "https://services.google.com/fh/files/misc/core-products-icons.zip" -o gcp-core-products-icons.zip -$ curl -fL "https://services.google.com/fh/files/misc/category-icons.zip" -o gcp-category-icons.zip -$ stack icons import gcp ./gcp-core-products-icons.zip \ - --source categories=./gcp-category-icons.zip \ - --accept-terms -o .stack-icons/gcp +Each import creates one known provider directory below that root: + +```text +icons/ + aws/ + manifest.json + NOTICE.md + assets/ + gcp/ + azure/ + simple-icons/ ``` -### Azure +`stack render` discovers the `aws`, `gcp`, `azure`, and `simple-icons` directories in this store. Each loaded manifest and its declared `assets/*.svg` files are validated before rendering. -Source: [Azure Architecture Icons](https://learn.microsoft.com/azure/architecture/icons/) +Set a different shared icon store in `$XDG_CONFIG_HOME/stack/config.yaml`: -```sh -$ curl -fL "https://arch-center.azureedge.net/icons/Azure_Public_Service_Icons_V24.zip" -o azure-icons.zip -$ stack icons import azure ./azure-icons.zip --accept-terms -o .stack-icons/azure +```yaml +default_icons_path: /absolute/path/to/stack-icons ``` -### Simple Icons +The configured path is used by both `stack icons import` and `stack render`. + +## Keep icons with a project -Source: [Simple Icons 16.29.0](https://github.com/simple-icons/simple-icons/releases/tag/16.29.0). This curated pack includes GitHub and other common developer and collaboration tools. +Use `-o` when the provider packs should live in a repository or another project-specific location. The option names the icon-store root, and the importer creates its provider child directory. ```sh -$ curl -fL "https://github.com/simple-icons/simple-icons/archive/refs/tags/16.29.0.zip" -o simple-icons-16.29.0.zip -$ stack icons import simple-icons ./simple-icons-16.29.0.zip --accept-terms -o .stack-icons/simple-icons +$ stack icons import gcp --accept-terms -o .stack-icons +$ stack icons import simple-icons --accept-terms -o .stack-icons ``` -## Usage +Use the same root with `--provider-pack` while rendering: ```sh -stack icons import aws ~/Downloads/aws-icons.zip \ - --accept-terms \ - -o .stack-icons/aws +$ stack render architecture.stack \ + --provider-pack .stack-icons \ + -o architecture.svg \ + --notice architecture.NOTICE.md ``` -`PROVIDER` is `aws`, `gcp`, `azure`, or `simple-icons`. The output directory must not exist. A successful import creates: +The project-local layout is: ```text -/ - manifest.json - NOTICE.md - assets/ - .svg +.stack-icons/ + gcp/ + simple-icons/ ``` -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. - -Google publishes the audited core-product and category icons as separate archives. The positional `ARCHIVE` is the primary core-products ZIP. `--source categories=` maps the second local ZIP to the required `categories` source ID; it is not a URL and does not download anything. - -```sh -stack icons import gcp /path/to/core-products-icons.zip \ - --source categories=/path/to/category-icons.zip \ - --accept-terms \ - -o .stack-icons/gcp -``` - -The additional source ID and both archive hashes are preserved in `manifest.json` and `NOTICE.md`. +`--provider-pack` takes precedence over `default_icons_path` for that render. `-o` takes precedence over it for that import. ## Finding IDs -Do not maintain a copied list of more than one thousand IDs in documentation. Search the versioned, asset-free catalog that ships with the CLI: +Search the versioned, asset-free catalog included in the CLI: ```sh # Provider counts and audited releases -stack icons list +$ stack icons list # Every AWS catalog entry -stack icons list aws +$ stack icons list aws -# Match ID, product name, or category -stack icons list aws s3 -stack icons list azure database -stack icons list simple-icons collaboration +# Match an ID, product name, or category +$ stack icons list aws s3 +$ stack icons list azure database +$ stack icons list simple-icons collaboration ``` -The tab-separated output has stable `ID`, `PRODUCT`, `CATEGORY`, and recommended `KIND` columns, so it can also be filtered or imported into another tool. Existing documented IDs remain stable when catalogs grow. - -## Rendering with local packs - -Use every imported directory explicitly when a diagram contains namespaced provider icons. For example, a diagram that uses `gcp:cloud-run` and `simple-icons:github` needs both packs: - -```sh -$ stack render architecture.stack \ - --provider-pack .stack-icons/gcp \ - --provider-pack .stack-icons/simple-icons \ - -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. +The tab-separated output contains stable `ID`, `PRODUCT`, `CATEGORY`, and recommended `KIND` columns. A provider icon supplies the visual asset, while the authored node `kind` controls semantic styling and layout behavior. ## Audited sources -| Provider | Catalog | Official archive | Audited release | Complete archive SHA-256 | Terms and guidance | +| Provider | Catalog | Official source | Audited release | Complete archive SHA-256 | Terms and guidance | | --- | ---: | --- | --- | --- | --- | | AWS | 305 | [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) | `Icon-package_07312026` | `d2d166c453526471749d520e0db022c459abef759d2946cf2dd1d1c992dc6526` | [AWS Trademark Guidelines](https://aws.amazon.com/trademark-guidelines/) | | Google Cloud core products | 19 | [Google Cloud Icon Library](https://cloud.google.com/icons) | May 2026 guide | `6531a10f58bc599c24d9a455d81dd757c1a03c3c43da9cddf639b859c1c1eece` | [Google Brand Resource Center](https://about.google/brand-resource-center/) | | Google Cloud product categories | 26 | [Google Cloud Icon Library](https://cloud.google.com/icons) | May 2026 guide | `e5bc3abd3527dc2500e9bff7f15870783e2c764129c49b7cd4c1b4e105345002` | [Google Brand Resource Center](https://about.google/brand-resource-center/) | -| Azure | 639 | [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 | -| Curated tools | 62 | [Simple Icons 16.29.0](https://github.com/simple-icons/simple-icons/releases/tag/16.29.0) | `16.29.0` | `99f30fabd5be19dab51e09b2adebb6fe54fce1f3709ddfdc936a1338dfebc68d` | [Simple Icons disclaimer](https://github.com/simple-icons/simple-icons/blob/16.29.0/DISCLAIMER.md), plus the per-icon brand links in the catalog and generated notice | +| Azure | 639 | [Azure Architecture Icons](https://learn.microsoft.com/azure/architecture/icons/) | `Azure_Public_Service_Icons_V24` | `921594ccd1bf3d9c0a1bd7b6d924e050551a59342f2b353bb74bdcf761c35141` | `Microsoft_Terms_of_Use.pdf` in the official archive and the source page | +| Curated tools | 62 | [Simple Icons 16.29.0](https://github.com/simple-icons/simple-icons/releases/tag/16.29.0) | `16.29.0` | `99f30fabd5be19dab51e09b2adebb6fe54fce1f3709ddfdc936a1338dfebc68d` | [Simple Icons disclaimer](https://github.com/simple-icons/simple-icons/blob/16.29.0/DISCLAIMER.md), plus per-icon brand links in the catalog and generated notice | + +The importer downloads every archive required by the selected provider. Google Cloud therefore imports its core-product and category archives together with one command. -The AWS and Azure catalogs cover every canonical service SVG entry selected from the audited official archives. Byte-identical Azure aliases are deduplicated, while visually distinct same-name entries keep distinct stable IDs. Google Cloud includes all 19 core-product and all 26 category SVGs in the two audited archives. The curated tools set covers common source control, collaboration, design, infrastructure, data, identity, and observability products, including GitHub, GitHub Actions, GitLab, Bitbucket, Notion, Linear, Atlassian, Jira, Confluence, Trello, Discord, Figma, Miro, Docker, Kubernetes, Terraform, OpenTofu, Pulumi, Datadog, Grafana, and Sentry. +The curated tools set covers common source control, collaboration, design, infrastructure, data, identity, and observability products, including GitHub, GitHub Actions, GitLab, Bitbucket, Notion, Linear, Atlassian, Jira, Confluence, Trello, Discord, Figma, Miro, Docker, Kubernetes, Terraform, OpenTofu, Pulumi, Datadog, Grafana, and Sentry. -Simple Icons makes its repository available under CC0, but its disclaimer explicitly separates that distribution license from rights in individual brand marks. The Stack catalog therefore records a source and guideline URL on every curated icon, and generated notices preserve those links. Inclusion is discoverability metadata, not an assertion that every use is permitted or endorsed. +## Verification and notices -## Security and artwork preservation +The CLI catalog pins each official HTTPS archive URL, release, complete archive SHA-256, allowlisted entry path, terms URL, and review date. Import applies a 32 MiB archive limit and a 1 MiB per-SVG limit before writing a new pack atomically. -The importer: +SVG processing uses a small element and attribute allowlist, preserves artwork geometry and colors, namespaces local resource identifiers, and records original and processed hashes plus visual-preservation transformations in `manifest.json`. -- caps the complete archive at 32 MiB and each selected SVG at 1 MiB; -- verifies every 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 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. +Each pack includes `NOTICE.md`. `stack render --notice ` writes the exact provider pack revisions, source releases, terms URLs, attribution, non-endorsement text, and used icon IDs for a rendered artifact. -Catalog metadata is validated for unique stable IDs and archive paths, exact source references, hashes, local-only processing, disabled redistribution targets, legacy ID compatibility, and required per-brand guidance. Real provider archives, source SVGs, and generated packs remain temporary local test inputs and are never committed. +Simple Icons distributes its repository under CC0 and documents separate rights for individual brand marks. The catalog records a source and guideline URL for every curated brand, and generated notices carry those links for use review. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7ce8e5b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,315 @@ +//! User-level Stack configuration discovery. + +use std::env; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +const MAX_CONFIG_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Environment { + xdg_config_home: Option, + home: Option, +} + +impl Environment { + pub(crate) fn capture() -> Self { + Self { + xdg_config_home: env::var_os("XDG_CONFIG_HOME"), + home: env::var_os("HOME"), + } + } + + #[cfg(test)] + fn new(xdg_config_home: Option<&Path>, home: Option<&Path>) -> Self { + Self { + xdg_config_home: xdg_config_home.map(Path::as_os_str).map(OsString::from), + home: home.map(Path::as_os_str).map(OsString::from), + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct StackConfig { + default_icons_path: Option, +} + +pub(crate) fn icon_store_root( + explicit_root: Option<&Path>, + environment: &Environment, +) -> Result { + if let Some(root) = explicit_root { + return Ok(root.to_owned()); + } + + let config_root = config_root(environment)?; + let stack_root = config_root.join("stack"); + let config_path = stack_root.join("config.yaml"); + let config = read_config(&config_path)?; + if let Some(default_icons_path) = config.default_icons_path { + if !default_icons_path.is_absolute() { + return Err(format!( + "config '{}' must set 'default_icons_path' to an absolute path", + config_path.display() + )); + } + return Ok(default_icons_path); + } + Ok(stack_root.join("icons")) +} + +fn config_root(environment: &Environment) -> Result { + if let Some(value) = &environment.xdg_config_home { + if !value.is_empty() { + let path = PathBuf::from(value); + if path.is_absolute() { + return Ok(path); + } + } + } + + if let Some(value) = &environment.home { + if !value.is_empty() { + let home = PathBuf::from(value); + if home.is_absolute() { + return Ok(home.join(".config")); + } + } + } + Err( + "cannot determine the Stack config directory; set XDG_CONFIG_HOME or HOME to an absolute path" + .to_owned(), + ) +} + +fn read_config(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StackConfig::default()); + } + Err(error) => { + return Err(format!( + "cannot read config '{}': {}", + path.display(), + stable_io_error(error.kind()) + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "config '{}' must be a regular file, not a symlink", + path.display() + )); + } + if metadata.len() > MAX_CONFIG_BYTES as u64 { + return Err(format!( + "config '{}' exceeds the 64 KiB limit", + path.display() + )); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + let read_result = File::open(path).and_then(|mut file| file.read_to_end(&mut bytes)); + if let Err(error) = read_result { + return Err(format!( + "cannot read config '{}': {}", + path.display(), + stable_io_error(error.kind()) + )); + } + if bytes.len() > MAX_CONFIG_BYTES { + return Err(format!( + "config '{}' exceeds the 64 KiB limit", + path.display() + )); + } + if bytes.iter().all(u8::is_ascii_whitespace) { + return Ok(StackConfig::default()); + } + match serde_yaml_ng::from_slice(&bytes) { + Ok(config) => Ok(config), + Err(_) => Err(format!("config '{}' is invalid YAML", path.display())), + } +} + +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::AlreadyExists => "already exists", + std::io::ErrorKind::InvalidInput => "invalid input", + _ => "I/O error", + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + static CASE_ID: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn new(label: &str) -> Self { + let case_id = CASE_ID.fetch_add(1, Ordering::Relaxed); + let path = env::temp_dir().join(format!( + "stack-cli-config-{}-{label}-{case_id}", + std::process::id() + )); + assert!(fs::create_dir(&path).is_ok()); + Self { path } + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn xdg_and_home_defaults_are_deterministic() { + let directory = TestDirectory::new("defaults"); + let xdg = directory.path.join("xdg"); + let home = directory.path.join("home"); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), Some(&home))), + Ok(path) if path == xdg.join("stack/icons") + )); + assert!(matches!( + icon_store_root( + None, + &Environment::new(Some(Path::new("relative")), Some(&home)) + ), + Ok(path) if path == home.join(".config/stack/icons") + )); + assert!(matches!( + icon_store_root(None, &Environment::new(None, Some(&home))), + Ok(path) if path == home.join(".config/stack/icons") + )); + assert!(icon_store_root(None, &Environment::new(None, None)).is_err()); + } + + #[test] + fn config_override_is_absolute_and_strict() { + let directory = TestDirectory::new("override"); + let xdg = directory.path.join("xdg"); + let stack_root = xdg.join("stack"); + assert!(fs::create_dir_all(&stack_root).is_ok()); + let custom = directory.path.join("shared-icons"); + assert!( + fs::write( + stack_root.join("config.yaml"), + format!("default_icons_path: {}\n", custom.display()), + ) + .is_ok() + ); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Ok(path) if path == custom + )); + + assert!( + fs::write( + stack_root.join("config.yaml"), + "default_icons_path: ./relative\n", + ) + .is_ok() + ); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Err(error) if error.contains("absolute path") + )); + assert!(fs::write(stack_root.join("config.yaml"), "unknown: true\n").is_ok()); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Err(error) if error.contains("invalid YAML") + )); + } + + #[test] + fn explicit_root_has_highest_precedence() { + let explicit = Path::new(".stack-icons"); + assert!(matches!( + icon_store_root(Some(explicit), &Environment::new(None, None)), + Ok(path) if path == explicit + )); + } + + #[test] + fn config_files_are_bounded_regular_files() { + let directory = TestDirectory::new("file-boundary"); + let xdg = directory.path.join("xdg"); + let stack_root = xdg.join("stack"); + let config_path = stack_root.join("config.yaml"); + assert!(fs::create_dir_all(&stack_root).is_ok()); + + assert!(fs::write(&config_path, b" \n\t").is_ok()); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Ok(path) if path == stack_root.join("icons") + )); + + assert!(fs::remove_file(&config_path).is_ok()); + assert!(fs::create_dir(&config_path).is_ok()); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Err(error) if error.contains("regular file") + )); + assert!(fs::remove_dir(&config_path).is_ok()); + + let oversized = File::create(&config_path); + assert!(oversized.is_ok()); + if let Ok(oversized) = oversized { + assert!(oversized.set_len(MAX_CONFIG_BYTES as u64 + 1).is_ok()); + } + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Err(error) if error.contains("64 KiB") + )); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + assert!(fs::remove_file(&config_path).is_ok()); + let target = stack_root.join("target.yaml"); + assert!(fs::write(&target, b"default_icons_path: /tmp/icons\n").is_ok()); + assert!(symlink(&target, &config_path).is_ok()); + assert!(matches!( + icon_store_root(None, &Environment::new(Some(&xdg), None)), + Err(error) if error.contains("symlink") + )); + } + + assert_eq!( + stable_io_error(std::io::ErrorKind::NotFound), + "file not found" + ); + assert_eq!( + stable_io_error(std::io::ErrorKind::PermissionDenied), + "permission denied" + ); + assert_eq!( + stable_io_error(std::io::ErrorKind::AlreadyExists), + "already exists" + ); + assert_eq!( + stable_io_error(std::io::ErrorKind::InvalidInput), + "invalid input" + ); + assert_eq!(stable_io_error(std::io::ErrorKind::Other), "I/O error"); + let _ = Environment::capture(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 46b183b..49fd8f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,18 +2,19 @@ #![forbid(unsafe_code)] -use std::collections::BTreeMap; 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::{Component, Path, PathBuf}; +use std::time::Duration; use stack_engine::{ CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, ProviderAsset, ProviderNotice, ProviderPack, RenderOutput, Severity, }; +mod config; mod provider; mod provider_catalog; @@ -24,17 +25,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 [--provider-pack ]... [-o ] [--notice ]\n stack icons list [PROVIDER] [QUERY]\n stack icons import [--source =]... --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 List catalogs and 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 list [PROVIDER] [QUERY]\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 List catalogs and import audited 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 [--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 list [PROVIDER] [QUERY]\n stack icons import [--source =]... --accept-terms -o \n\nProviders:\n aws 305 AWS Architecture Icons\n gcp 45 Google Cloud product and category icons\n azure 639 Azure Public Service Icons\n simple-icons 62 curated developer and collaboration tools\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 Read known provider packs from this icon-store root\n -o Write SVG atomically instead of using standard output\n --notice Write exact used-provider notices atomically\n\nDefault icon store:\n $XDG_CONFIG_HOME/stack/icons or $HOME/.config/stack/icons\n"; +const ICONS_HELP: &str = "Manage local provider icon packs\n\nUsage:\n stack icons list [PROVIDER] [QUERY]\n stack icons import --accept-terms [-o ]\n\nProviders:\n aws 305 AWS Architecture Icons\n gcp 45 Google Cloud product and category icons\n azure 639 Azure Public Service Icons\n simple-icons 62 curated developer and collaboration tools\n"; const ICONS_LIST_HELP: &str = "List searchable asset-free provider catalog metadata\n\nUsage:\n stack icons list\n stack icons list [QUERY]\n\nArguments:\n aws, gcp, azure, or simple-icons\n [QUERY] Case-insensitive ID, product, or category substring\n"; -const ICONS_IMPORT_HELP: &str = "Import audited provider icon archives locally\n\nUsage:\n stack icons import [--source =]... --accept-terms -o \n\nArguments:\n aws, gcp, azure, or simple-icons\n Local primary ZIP archive; Stack performs no download or upload\n\nOptions:\n --source = Map an additional local ZIP by source ID; GCP requires categories=\n --accept-terms Confirm that you reviewed all provider and brand terms\n -o Create a new local pack directory atomically\n"; +const ICONS_IMPORT_HELP: &str = "Download and import audited provider icon archives\n\nUsage:\n stack icons import --accept-terms [-o ]\n\nArguments:\n aws, gcp, azure, or simple-icons\n\nOptions:\n --accept-terms Confirm that you reviewed all provider and brand terms\n -o Store packs below this icon-store root\n\nDefault icon store:\n $XDG_CONFIG_HOME/stack/icons or $HOME/.config/stack/icons\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 { @@ -48,6 +48,8 @@ enum RenderDestination { File(PathBuf), } +type ImportProvider = fn(&str, &Path) -> Result; + /// Runs the CLI with explicit streams and returns its process exit status. pub fn run( arguments: impl IntoIterator, @@ -170,6 +172,20 @@ fn run_icons_import( arguments: &mut dyn Iterator, stdout: &mut dyn Write, stderr: &mut dyn Write, +) -> u8 { + run_icons_import_with( + arguments, + stdout, + stderr, + import_provider_from_official_sources, + ) +} + +fn run_icons_import_with( + arguments: &mut dyn Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, + import: ImportProvider, ) -> u8 { let Some(provider) = arguments.next() else { return argument_error("missing provider for 'stack icons import'", stderr); @@ -189,19 +205,20 @@ fn run_icons_import( stderr, ); } - let Some(archive) = arguments.next() else { - return argument_error("missing archive for 'stack icons import'", stderr); + let Some(provider_name) = provider.to_str() else { + return argument_error("provider must be valid UTF-8", stderr); }; - if archive.to_string_lossy().starts_with('-') { + if !provider_catalog::PROVIDER_IDS.contains(&provider_name) { return argument_error( - &format!("unknown option '{}'", archive.to_string_lossy()), + &format!( + "unknown provider '{provider_name}'; expected aws, gcp, azure, or simple-icons" + ), stderr, ); } let mut accepted_terms = false; - let mut output = None; - let mut additional_sources = BTreeMap::new(); + let mut output_root = None; while let Some(option) = arguments.next() { if option == OsStr::new("--accept-terms") { if accepted_terms { @@ -209,32 +226,13 @@ fn run_icons_import( } accepted_terms = true; } else if option == OsStr::new("-o") { - if output.is_some() { + if output_root.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 if option == OsStr::new("--source") { - let Some(value) = arguments.next() else { - return argument_error("missing = after '--source'", stderr); - }; - let Some(value) = value.to_str() else { - return argument_error("source ID and archive must be valid UTF-8", stderr); - }; - let Some((id, path)) = value.split_once('=') else { - return argument_error("source must use =", stderr); - }; - if id.is_empty() || path.is_empty() { - return argument_error("source must use non-empty =", stderr); - } - if additional_sources - .insert(id.to_owned(), PathBuf::from(path)) - .is_some() - { - return argument_error(&format!("duplicate source ID '{id}'"), stderr); - } + output_root = Some(PathBuf::from(path)); } else { return argument_error( &format!("unexpected argument '{}'", option.to_string_lossy()), @@ -248,25 +246,14 @@ fn run_icons_import( stderr, ); } - let Some(output) = output else { - return argument_error("missing output directory after '-o'", stderr); + let environment = config::Environment::capture(); + let icon_store_root = match config::icon_store_root(output_root.as_deref(), &environment) { + Ok(path) => path, + Err(error) => return write_stderr_error(&error, stderr), }; - if output == Path::new(&archive) { - return argument_error("archive and output directory must be different", stderr); - } - if additional_sources.values().any(|path| path == &output) { - return argument_error( - "source 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), - &additional_sources, - &output, - ) { + let output = icon_store_root.join(provider_name); + let result = import(provider_name, &output); + match result { Ok(summary) => write_stdout( &format!( "Imported {} {} icons to '{}'.\nManifest: {}\nNotice: {}\n", @@ -283,6 +270,38 @@ fn run_icons_import( } } +fn import_provider_from_official_sources( + provider: &str, + output: &Path, +) -> Result { + let mut download = download_provider_archive; + provider::import_provider_pack_from_official_sources(provider, output, &mut download) +} + +fn download_provider_archive(url: &str, limit: u64) -> Result, String> { + let config = ureq::Agent::config_builder() + .https_only(true) + .timeout_global(Some(Duration::from_secs(120))) + .build(); + let agent = ureq::Agent::new_with_config(config); + let response = agent.get(url).call(); + let mut response = match response { + Ok(response) => response, + Err(error) => { + return Err(format!( + "cannot download audited archive from '{url}': {error}" + )); + } + }; + let body = response.body_mut().with_config().limit(limit).read_to_vec(); + match body { + Ok(bytes) => Ok(bytes), + Err(error) => Err(format!( + "cannot download audited archive from '{url}': {error}" + )), + } +} + fn run_render( mut arguments: impl Iterator, stdout: &mut dyn Write, @@ -309,7 +328,7 @@ fn run_render( let mut destination = None; let mut notice_path = None; - let mut provider_pack_paths = Vec::new(); + let mut provider_pack_root = None; while let Some(option) = arguments.next() { if option == OsStr::new("-o") { if destination.is_some() { @@ -328,13 +347,13 @@ fn run_render( }; notice_path = Some(PathBuf::from(path)); } else if option == OsStr::new("--provider-pack") { + if provider_pack_root.is_some() { + return argument_error("duplicate '--provider-pack' option", stderr); + } let Some(path) = arguments.next() else { - return argument_error("missing provider pack directory", stderr); + return argument_error("missing provider icon-store 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); - } + provider_pack_root = Some(PathBuf::from(path)); } else { return argument_error( &format!("unexpected argument '{}'", option.to_string_lossy()), @@ -357,10 +376,19 @@ fn run_render( return argument_error("output and notice files must be different", stderr); } + let explicit_provider_pack_root = provider_pack_root.is_some(); + let environment = config::Environment::capture(); + let provider_pack_root = + match config::icon_store_root(provider_pack_root.as_deref(), &environment) { + Ok(path) => path, + Err(error) => return write_stderr_error(&error, stderr), + }; + render_file( Path::new(&source), destination, - &provider_pack_paths, + &provider_pack_root, + !explicit_provider_pack_root, notice_path.as_deref(), stdout, stderr, @@ -490,27 +518,25 @@ fn check_file_with( fn render_file( path: &Path, destination: RenderDestination, - provider_pack_paths: &[PathBuf], + provider_pack_root: &Path, + allow_missing_provider_pack_root: bool, 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, + let provider_packs = + match load_provider_store(provider_pack_root, allow_missing_provider_pack_root) { + Ok(provider_packs) => provider_packs, Err(reason) => { return write_stderr_error( &format!( - "cannot load provider pack '{}': {reason}", - provider_pack_path.display() + "cannot load provider icon store '{}': {reason}", + provider_pack_root.display() ), stderr, ); } }; - provider_packs.push(provider_pack); - } let engine = match Engine::with_provider_packs(&provider_packs) { Ok(engine) => engine, Err(error) => { @@ -528,6 +554,36 @@ fn render_file( ) } +fn load_provider_store(root: &Path, allow_missing: bool) -> Result, String> { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(stable_io_error(error.kind()).to_owned()), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("store path must be a real directory, not a symlink".to_owned()); + } + + let mut packs = Vec::new(); + for provider_id in provider_catalog::PROVIDER_IDS { + let pack_root = root.join(provider_id); + match fs::symlink_metadata(&pack_root) { + Ok(_) => packs.push(load_provider_pack_for(&pack_root, provider_id)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot inspect '{}': {}", + pack_root.display(), + stable_io_error(error.kind()) + )); + } + } + } + Ok(packs) +} + fn render_file_with( path: &Path, destination: RenderDestination, @@ -605,7 +661,19 @@ fn render_file_with( EXIT_SUCCESS } +#[cfg(test)] fn load_provider_pack(root: &Path) -> Result { + load_provider_pack_with_expected_id(root, None) +} + +fn load_provider_pack_for(root: &Path, expected_provider_id: &str) -> Result { + load_provider_pack_with_expected_id(root, Some(expected_provider_id)) +} + +fn load_provider_pack_with_expected_id( + root: &Path, + expected_provider_id: Option<&str>, +) -> 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() { @@ -616,6 +684,14 @@ fn load_provider_pack(root: &Path) -> Result { 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())?; + if let Some(expected_provider_id) = expected_provider_id { + if manifest.provider.id != expected_provider_id { + return Err(format!( + "manifest provider '{}' does not match directory '{}'", + manifest.provider.id, expected_provider_id + )); + } + } let mut assets = Vec::with_capacity(manifest.icons.len()); for icon in &manifest.icons { let relative = safe_provider_asset_path(&icon.asset.path)?; @@ -1047,7 +1123,7 @@ mod tests { run_without_input([OsString::from("--version")], &mut stdout, &mut stderr), EXIT_SUCCESS ); - assert_eq!(stdout, b"stack 0.2.0\n"); + assert_eq!(stdout, b"stack 0.3.0\n"); assert!(stderr.is_empty()); stdout.clear(); @@ -1240,18 +1316,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 duplicate_provider_store = vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("--provider-pack"), + OsString::from("first"), + OsString::from("--provider-pack"), + OsString::from("second"), + ]; let mut stdout = Vec::new(); let mut stderr = Vec::new(); assert_eq!( - run_without_input(too_many_packs, &mut stdout, &mut stderr), + run_without_input(duplicate_provider_store, &mut stdout, &mut stderr), EXIT_USAGE_OR_IO ); - assert!(String::from_utf8_lossy(&stderr).contains("at most 32 provider packs")); + assert!(String::from_utf8_lossy(&stderr).contains("duplicate '--provider-pack'")); stdout.clear(); stderr.clear(); @@ -1367,6 +1446,64 @@ mod tests { assert!(stderr.is_empty()); } + #[test] + fn provider_import_command_resolves_a_store_root_and_reports_results() { + fn successful_import( + provider_id: &str, + output: &Path, + ) -> Result { + assert_eq!(provider_id, "gcp"); + assert_eq!(output, Path::new(".stack-icons/gcp")); + Ok(provider::ImportSummary { + provider_name: "Google Cloud".to_owned(), + icon_count: 45, + manifest_path: output.join("manifest.json"), + notice_path: output.join("NOTICE.md"), + }) + } + + fn failed_import( + _: &str, + _: &Path, + ) -> Result { + Err(provider::ImportError::new("download failed")) + } + + let mut arguments = [ + OsString::from("gcp"), + OsString::from("--accept-terms"), + OsString::from("-o"), + OsString::from(".stack-icons"), + ] + .into_iter(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = + run_icons_import_with(&mut arguments, &mut stdout, &mut stderr, successful_import); + assert_eq!(status, EXIT_SUCCESS); + assert!(stderr.is_empty()); + let rendered_stdout = String::from_utf8_lossy(&stdout); + assert!(rendered_stdout.contains("Imported 45 Google Cloud icons")); + assert!(rendered_stdout.contains(".stack-icons/gcp/manifest.json")); + + let mut arguments = [ + OsString::from("aws"), + OsString::from("--accept-terms"), + OsString::from("-o"), + OsString::from(".stack-icons"), + ] + .into_iter(); + stdout.clear(); + stderr.clear(); + let status = run_icons_import_with(&mut arguments, &mut stdout, &mut stderr, failed_import); + assert_eq!(status, EXIT_USAGE_OR_IO); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).contains("download failed")); + + assert!(import_provider_from_official_sources("unknown", Path::new("unused")).is_err()); + assert!(download_provider_archive("http://example.com/archive.zip", 1).is_err()); + } + #[test] fn diagnostic_rendering_includes_related_locations() { let diagnostic = Diagnostic { @@ -1743,6 +1880,22 @@ mod tests { assert!(fs::remove_dir_all(root).is_ok()); } + #[test] + fn provider_store_discovers_only_known_provider_directories() { + let root = + std::env::temp_dir().join(format!("stack-cli-provider-store-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + assert!(load_provider_store(&root, true).is_ok_and(|packs| packs.is_empty())); + assert!(fs::create_dir_all(root.join("unknown")).is_ok()); + assert!(fs::write(root.join("unknown/manifest.json"), b"invalid").is_ok()); + assert!(load_provider_store(&root, false).is_ok_and(|packs| packs.is_empty())); + + assert!(fs::create_dir_all(root.join("aws")).is_ok()); + assert!(fs::write(root.join("aws/manifest.json"), b"invalid").is_ok()); + assert!(load_provider_store(&root, false).is_err()); + assert!(fs::remove_dir_all(root).is_ok()); + } + #[test] fn provider_notice_text_is_stable_and_inert() { assert_eq!( diff --git a/src/provider.rs b/src/provider.rs index 7fa7aeb..0e4ad49 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,4 +1,4 @@ -//! Local-only provider icon archive import. +//! Audited provider icon archive import. use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Write as _}; @@ -18,11 +18,13 @@ use crate::provider_catalog::{ProviderCatalog, provider_catalog, provider_catalo 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; +pub(crate) 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 XLINK_NAMESPACE: &str = "http://www.w3.org/1999/xlink"; +type ArchiveFetcher<'a> = dyn FnMut(&str, u64) -> Result, String> + 'a; + const ALLOWED_ELEMENTS: &[&str] = &[ "circle", "clipPath", @@ -102,7 +104,7 @@ pub(crate) struct ImportError { } impl ImportError { - fn new(message: impl Into) -> Self { + pub(crate) fn new(message: impl Into) -> Self { Self { message: message.into(), } @@ -129,28 +131,77 @@ struct ParsedViewBox { } /// Imports one audited official archive into a new local pack directory. +#[cfg(test)] pub(crate) fn import_provider_pack( provider: &str, archive_path: &Path, additional_archive_paths: &BTreeMap, output_path: &Path, ) -> Result { - let profile = provider_catalog(provider).map_err(ImportError::new)?; + let profile = match provider_catalog(provider) { + Ok(profile) => profile, + Err(error) => return Err(ImportError::new(error)), + }; import_profile(profile, archive_path, additional_archive_paths, output_path) } +/// Downloads every audited official source and imports it into a new local pack directory. +pub(crate) fn import_provider_pack_from_official_sources( + provider: &str, + output_path: &Path, + fetch: &mut ArchiveFetcher<'_>, +) -> Result { + let profile = provider_catalog(provider).map_err(ImportError::new)?; + import_profile_from_official_sources(profile, output_path, fetch) +} + +fn import_profile_from_official_sources( + profile: ProviderCatalog, + output_path: &Path, + fetch: &mut ArchiveFetcher<'_>, +) -> Result { + ensure_output_available(output_path)?; + let mut archives = BTreeMap::new(); + archives.insert("primary".to_owned(), fetch_source(&profile.source, fetch)?); + for additional in &profile.additional_sources { + archives.insert( + additional.id.clone(), + fetch_source(&additional.source, fetch)?, + ); + } + import_profile_bytes(profile, archives, output_path) +} + +fn fetch_source( + source: &ProviderPackSource, + fetch: &mut ArchiveFetcher<'_>, +) -> Result, ImportError> { + if !source.archive_url.starts_with("https://") { + return Err(ImportError::new( + "audited provider archive URL must use HTTPS", + )); + } + let bytes = match fetch(&source.archive_url, MAX_ARCHIVE_BYTES) { + Ok(bytes) => bytes, + Err(error) => return Err(ImportError::new(error)), + }; + if bytes.len() as u64 > MAX_ARCHIVE_BYTES { + return Err(ImportError::new( + "downloaded archive exceeds the 32 MiB limit", + )); + } + verify_archive_hash(&bytes, source)?; + Ok(bytes) +} + +#[cfg(test)] fn import_profile( profile: ProviderCatalog, archive_path: &Path, additional_archive_paths: &BTreeMap, output_path: &Path, ) -> Result { - if output_path.exists() { - return Err(ImportError::new(format!( - "output '{}' already exists", - output_path.display() - ))); - } + ensure_output_available(output_path)?; let expected_additional = profile .additional_sources .iter() @@ -174,20 +225,44 @@ fn import_profile( ))); } - let mut archives = BTreeMap::new(); - archives.insert( + let mut archive_bytes = BTreeMap::new(); + archive_bytes.insert( "primary".to_owned(), - open_archive(archive_path, &profile.source)?, + read_archive(archive_path, &profile.source)?, ); for additional in &profile.additional_sources { let Some(path) = additional_archive_paths.get(&additional.id) else { return Err(ImportError::new("missing additional provider archive")); }; - archives.insert( + archive_bytes.insert( additional.id.clone(), - open_archive(path, &additional.source)?, + read_archive(path, &additional.source)?, ); } + import_profile_bytes(profile, archive_bytes, output_path) +} + +fn import_profile_bytes( + profile: ProviderCatalog, + archive_bytes: BTreeMap>, + output_path: &Path, +) -> Result { + ensure_output_available(output_path)?; + let mut archives = BTreeMap::new(); + for (id, bytes) in archive_bytes { + let source = if id == "primary" { + &profile.source + } else { + profile + .additional_sources + .iter() + .find(|source| source.id == id) + .map(|source| &source.source) + .ok_or_else(|| ImportError::new(format!("unknown archive source '{id}'")))? + }; + verify_archive_hash(&bytes, source)?; + archives.insert(id, open_archive_bytes(bytes)?); + } let mut manifest_icons = Vec::with_capacity(profile.icons.len()); let mut processed_assets = Vec::with_capacity(profile.icons.len()); @@ -267,9 +342,28 @@ fn import_profile( Some(path) if !path.as_os_str().is_empty() => path, _ => Path::new("."), }; - if !parent.is_dir() { + if !parent.exists() { + if let Err(error) = fs::create_dir_all(parent) { + return Err(ImportError::new(format!( + "output parent '{}' is not a directory or cannot be created: {}", + parent.display(), + stable_io_error(error.kind()) + ))); + } + } + let parent_metadata = match fs::symlink_metadata(parent) { + Ok(metadata) => metadata, + Err(error) => { + return Err(ImportError::new(format!( + "cannot inspect output parent '{}': {}", + parent.display(), + stable_io_error(error.kind()) + ))); + } + }; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { return Err(ImportError::new(format!( - "output parent '{}' is not a directory", + "output parent '{}' is not a directory or is a symlink", parent.display() ))); } @@ -301,10 +395,18 @@ fn import_profile( }) } -fn open_archive( - archive_path: &Path, - source: &ProviderPackSource, -) -> Result>>, ImportError> { +fn ensure_output_available(output_path: &Path) -> Result<(), ImportError> { + if output_path.exists() { + return Err(ImportError::new(format!( + "output '{}' already exists", + output_path.display() + ))); + } + Ok(()) +} + +#[cfg(test)] +fn read_archive(archive_path: &Path, source: &ProviderPackSource) -> Result, ImportError> { let metadata = match fs::metadata(archive_path) { Ok(metadata) => metadata, Err(error) => { @@ -337,14 +439,23 @@ fn open_archive( ))); } }; - let archive_digest = sha256(&archive_bytes); + verify_archive_hash(&archive_bytes, source)?; + Ok(archive_bytes) +} + +fn verify_archive_hash(bytes: &[u8], source: &ProviderPackSource) -> Result<(), ImportError> { + let archive_digest = sha256(bytes); if archive_digest != source.archive_sha256 { return Err(ImportError::new(format!( "archive hash does not match the audited {} release; expected {}, received {}", source.release, source.archive_sha256, archive_digest ))); } - ZipArchive::new(Cursor::new(archive_bytes)) + Ok(()) +} + +fn open_archive_bytes(bytes: Vec) -> Result>>, ImportError> { + ZipArchive::new(Cursor::new(bytes)) .map_err(|_| ImportError::new("archive is not a supported ZIP file")) } @@ -1144,7 +1255,7 @@ fn create_temporary_directory(parent: &Path) -> Result { fn render_notice(manifest: &ProviderPack) -> String { let mut notice = format!( - "# Stack provider icon pack notice\n\nProvider: {} (`{}`)\n\n{}\n\n{}\n\n{}\n\nThis local pack was created from archives selected by the user. Stack does not redistribute these asset bytes. Use and distribute generated diagrams only as permitted by the linked provider and brand terms.\n\n## Sources\n", + "# Stack provider icon pack notice\n\nProvider: {} (`{}`)\n\n{}\n\n{}\n\n{}\n\nThis local pack was created from audited official archives. Stack does not redistribute these asset bytes. Use and distribute generated diagrams only as permitted by the linked provider and brand terms.\n\n## Sources\n", manifest.provider.name, manifest.provider.id, manifest.notice.attribution, @@ -1321,6 +1432,113 @@ mod tests { assert!(provider_catalog("unknown").is_err()); } + #[test] + fn official_import_fetches_verified_sources_before_creating_the_store() { + let root = temporary_root("official-success"); + let _ = fs::remove_dir_all(&root); + let output_path = root.join("icons/example"); + let svg = br##""##; + let archive = zip_with_entry("icons/storage.svg", svg); + let profile = test_profile(&sha256(&archive), "icons/storage.svg"); + assert!(profile.is_ok()); + let Ok(mut profile) = profile else { + return; + }; + let gcp = provider_catalog("gcp"); + assert!(gcp.is_ok()); + let Ok(gcp) = gcp else { + return; + }; + let Some(mut additional) = gcp.additional_sources.into_iter().next() else { + return; + }; + additional.source.archive_url = "https://example.com/categories.zip".to_owned(); + additional.source.archive_sha256 = sha256(&archive); + profile.additional_sources.push(additional); + let mut fetched_urls = Vec::new(); + let mut fetch = |url: &str, limit: u64| { + fetched_urls.push(url.to_owned()); + assert_eq!(limit, MAX_ARCHIVE_BYTES); + Ok(archive.clone()) + }; + let imported = import_profile_from_official_sources(profile, &output_path, &mut fetch); + assert!(imported.is_ok()); + assert_eq!( + fetched_urls, + [ + "https://example.com/icons.zip", + "https://example.com/categories.zip" + ] + ); + assert!(output_path.join("manifest.json").is_file()); + assert!(output_path.join("assets/storage.svg").is_file()); + assert!(fs::remove_dir_all(&root).is_ok()); + } + + #[test] + fn official_import_rejects_unverified_or_insecure_archives_without_output() { + let root = temporary_root("official-rejected"); + let _ = fs::remove_dir_all(&root); + let output_path = root.join("icons/example"); + let mut fetch = |_: &str, _: u64| Ok(Vec::new()); + assert!( + import_provider_pack_from_official_sources("unknown", &output_path, &mut fetch) + .err() + .is_some_and(|error| error.to_string().contains("unknown provider")) + ); + let valid_archive = zip_with_entry( + "icons/storage.svg", + br#""#, + ); + let profile = test_profile(&sha256(&valid_archive), "icons/storage.svg"); + assert!(profile.is_ok()); + let Ok(profile) = profile else { + return; + }; + let mut fetch = |_: &str, _: u64| Ok(b"changed archive".to_vec()); + let rejected = import_profile_from_official_sources(profile, &output_path, &mut fetch); + assert!( + rejected + .err() + .is_some_and(|error| error.to_string().contains("archive hash does not match")) + ); + assert!(!root.exists()); + + let profile = test_profile(&sha256(&valid_archive), "icons/storage.svg"); + assert!(profile.is_ok()); + let Ok(mut profile) = profile else { + return; + }; + profile.source.archive_url = "http://example.com/icons.zip".to_owned(); + let mut fetched = false; + let mut fetch = |_: &str, _: u64| { + fetched = true; + Ok(valid_archive.clone()) + }; + let rejected = import_profile_from_official_sources(profile, &output_path, &mut fetch); + assert!( + rejected + .err() + .is_some_and(|error| error.to_string().contains("must use HTTPS")) + ); + assert!(!fetched); + assert!(!root.exists()); + + let profile = test_profile("sha256:unused", "icons/storage.svg"); + assert!(profile.is_ok()); + let Ok(profile) = profile else { + return; + }; + let mut fetch = |_: &str, _: u64| Ok(vec![0; MAX_ARCHIVE_BYTES as usize + 1]); + let rejected = import_profile_from_official_sources(profile, &output_path, &mut fetch); + assert!( + rejected + .err() + .is_some_and(|error| error.to_string().contains("32 MiB limit")) + ); + assert!(!root.exists()); + } + #[test] fn sanitizer_preserves_colors_and_namespaces_gradients() { let source = br##"Storage"##; diff --git a/src/provider_catalog.rs b/src/provider_catalog.rs index 572f07f..12c5cf4 100644 --- a/src/provider_catalog.rs +++ b/src/provider_catalog.rs @@ -11,6 +11,8 @@ const GCP_CATALOG: &str = include_str!("../catalogs/gcp.json"); const AZURE_CATALOG: &str = include_str!("../catalogs/azure.json"); const SIMPLE_ICONS_CATALOG: &str = include_str!("../catalogs/simple-icons.json"); +pub(crate) const PROVIDER_IDS: [&str; 4] = ["aws", "gcp", "azure", "simple-icons"]; + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ProviderCatalog { @@ -65,10 +67,7 @@ pub(crate) fn provider_catalog(provider: &str) -> Result Result, String> { - ["aws", "gcp", "azure", "simple-icons"] - .into_iter() - .map(provider_catalog) - .collect() + PROVIDER_IDS.into_iter().map(provider_catalog).collect() } #[cfg(test)] diff --git a/tests/cli.rs b/tests/cli.rs index c7ad4e7..97927fe 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -40,6 +40,10 @@ impl Drop for TestDirectory { fn stack(arguments: impl IntoIterator>) -> Result> { Ok(Command::new(env!("CARGO_BIN_EXE_stack")) .args(arguments) + .env( + "XDG_CONFIG_HOME", + env::temp_dir().join(format!("stack-cli-empty-config-{}", std::process::id())), + ) .output()?) } @@ -49,6 +53,10 @@ fn stack_with_input( ) -> Result> { let mut child = Command::new(env!("CARGO_BIN_EXE_stack")) .args(arguments) + .env( + "XDG_CONFIG_HOME", + env::temp_dir().join(format!("stack-cli-empty-config-{}", std::process::id())), + ) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -150,7 +158,7 @@ fn help_and_version_are_stdout_only() -> Result<(), Box> { let version = stack(["--version"])?; assert_eq!(version.status.code(), Some(0)); - assert_eq!(version.stdout, b"stack 0.2.0\n"); + assert_eq!(version.stdout, b"stack 0.3.0\n"); assert!(version.stderr.is_empty()); Ok(()) } diff --git a/tests/render.rs b/tests/render.rs index f9f8d48..8d2419a 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -2,7 +2,7 @@ use std::env; use std::error::Error; use std::ffi::OsStr; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -41,9 +41,42 @@ impl Drop for TestDirectory { fn stack(arguments: impl IntoIterator>) -> Result> { Ok(Command::new(env!("CARGO_BIN_EXE_stack")) .args(arguments) + .env( + "XDG_CONFIG_HOME", + env::temp_dir().join(format!("stack-cli-empty-config-{}", std::process::id())), + ) .output()?) } +fn stack_with_config_root( + config_root: &Path, + arguments: impl IntoIterator>, +) -> Result> { + Ok(Command::new(env!("CARGO_BIN_EXE_stack")) + .args(arguments) + .env("XDG_CONFIG_HOME", config_root) + .output()?) +} + +fn create_provider_store(root: &Path) -> Result<(), Box> { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("provider-pack"); + let pack = root.join("simple-icons"); + fs::create_dir_all(pack.join("assets"))?; + let manifest = fs::read_to_string(fixture.join("manifest.json"))? + .replace("\"example\"", "\"simple-icons\"") + .replace("example:storage", "simple-icons:storage") + .replace("Example Cloud", "Simple Icons Fixture"); + fs::write(pack.join("manifest.json"), manifest)?; + fs::copy( + fixture.join("assets/storage.svg"), + pack.join("assets/storage.svg"), + )?; + Ok(()) +} + fn engine_svg(source: &[u8]) -> Result> { let output = Engine::bundled().render(source)?; output @@ -54,20 +87,18 @@ fn engine_svg(source: &[u8]) -> Result> { #[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 source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind queue icon \"simple-icons: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 provider_store = directory.path.join("icons"); + create_provider_store(&provider_store)?; let output = stack([ OsStr::new("render"), input.as_os_str(), OsStr::new("--provider-pack"), - provider_pack.as_os_str(), + provider_store.as_os_str(), OsStr::new("--notice"), notice_path.as_os_str(), OsStr::new("-o"), @@ -79,16 +110,46 @@ fn local_provider_pack_renders_namespaced_icon_and_exact_notice() -> Result<(), 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("data-icon-id=\"simple-icons:storage\"")); assert!(svg.contains("fill=\"#4285f4\"")); let notice = fs::read_to_string(notice_path)?; - assert!(notice.contains("## Example Cloud (`example`)")); + assert!(notice.contains("## Simple Icons Fixture (`simple-icons`)")); assert!(notice.contains("https://example.com/terms")); - assert!(notice.contains("`example:storage`: Example Storage")); + assert!(notice.contains("`simple-icons:storage`: Example Storage")); assert!(notice.contains("Pack revision: `sha256:")); Ok(()) } +#[test] +fn default_store_and_config_override_are_loaded_without_render_flags() -> Result<(), Box> +{ + let directory = TestDirectory::new("default-provider-store")?; + let config_root = directory.path.join("config"); + let custom_store = directory.path.join("shared-icons"); + create_provider_store(&custom_store)?; + fs::create_dir_all(config_root.join("stack"))?; + fs::write( + config_root.join("stack/config.yaml"), + format!("default_icons_path: {}\n", custom_store.display()), + )?; + fs::create_dir_all(custom_store.join("ignored-provider"))?; + fs::write( + custom_store.join("ignored-provider/manifest.json"), + b"not a provider pack", + )?; + + let source = b"stack 1.0 diagram \"Provider\" { node item \"Example Storage\" { kind storage icon \"simple-icons:storage\" } }"; + let input = directory.file("provider.stack", source)?; + let output = stack_with_config_root(&config_root, [OsStr::new("render"), input.as_os_str()])?; + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let svg = String::from_utf8(output.stdout)?; + assert!(svg.contains("data-icon-id=\"simple-icons:storage\"")); + assert!(svg.contains("fill=\"#4285f4\"")); + Ok(()) +} + #[test] fn stdout_is_exactly_the_engine_svg() -> Result<(), Box> { let directory = TestDirectory::new("stdout")?;