M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
[package]
license = "Apache-2.0"
name = "kigi-mermaid"
version.workspace = true
edition.workspace = true
description = "Render Mermaid diagram source to a rasterized PNG behind a swappable engine trait"
[dependencies]
# SVG -> PNG rasterization. `resvg` re-exports the matching `usvg` (0.47) and
# `tiny_skia`; we deliberately use `resvg::usvg` rather than the workspace
# `usvg` (0.45.1) so `resvg::render` typechecks against the tree we parse.
resvg = { workspace = true }
tiny-skia = { workspace = true }
fontdb = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
which = { workspace = true }
# Subprocess infra (MmdcEngine + the out-of-process render child): detached
# spawning + wall-clock timeout + process-group reaping.
kigi-tty-utils = { workspace = true }
wait-timeout = { workspace = true }
tempfile = { workspace = true }
# Vendored dagre-based Mermaid -> SVG engine (third_party/mermaid-to-svg, MIT).
# Always compiled — the default engine, no feature gate.
mermaid-to-svg = { workspace = true }
# Subprocess process-group reaping (killpg) for the sync subprocess path, since
# kigi-tty-utils' ProcessGroup only wraps tokio children.
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[dev-dependencies]
# `png` codec enabled LOCALLY only (workspace pins image with
# default-features=false; "Do not add features globally"). Used by tests to
# decode the output PNG and assert its dimensions.
image = { workspace = true, features = ["png"] }
@@ -0,0 +1,210 @@
Roboto-Regular.ttf
Copyright 2011 Google Inc. All Rights Reserved.
This font is bundled (via include_bytes!) into the distributed Grok CLI binary
and is redistributed under the Apache License, Version 2.0 (below). It is used as
a deterministic fallback face so diagram text metrics do not depend on system
fonts. Retain this notice alongside the font when redistributing.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+298
View File
@@ -0,0 +1,298 @@
//! The [`MermaidEngine`] trait, error type, resource limits, and the
//! panic-isolating [`render_checked`] entry point.
use std::panic::AssertUnwindSafe;
use crate::{RenderParams, RenderedDiagram};
/// Why a diagram failed to render.
///
/// Every variant maps to the same user-facing outcome (fall back to the source
/// code block); they differ only for observability. [`Panic`](Self::Panic)
/// carries the message of an engine panic isolated by [`render_checked`] (see
/// its docs for the `panic = "unwind"` requirement).
#[derive(thiserror::Error, Debug)]
pub enum MermaidError {
/// The source could not be parsed into a diagram.
#[error("mermaid parse error: {0}")]
Parse(String),
/// The diagram parsed but layout failed.
#[error("mermaid layout error: {0}")]
Layout(String),
/// The SVG could not be rasterized to PNG.
#[error("mermaid rasterize error: {0}")]
Rasterize(String),
/// An external engine exceeded its wall-clock budget.
#[error("mermaid render timed out")]
Timeout,
/// The engine cannot render this input (unknown/exotic diagram, disabled
/// engine, or a breached resource limit such as oversized source).
#[error("mermaid render unsupported: {0}")]
Unsupported(String),
/// The engine panicked and [`render_checked`] caught it and converted it to
/// this error. **Only intercepted when the binary is built with
/// `panic = "unwind"`**; under `panic = "abort"` the process aborts instead
/// (see [`render_checked`]). Carries the panic message.
#[error("mermaid engine panicked: {0}")]
Panic(String),
}
/// Caps applied by [`render_checked`] before the engine so untrusted source
/// can't trivially exhaust memory via an oversized payload.
///
/// This enforces `max_source_bytes`. A wall-clock timeout for a runaway render
/// is enforced *out of process* by the caller (the pager renders each diagram in
/// a short-lived child via [`crate::run_with_timeout`], which a synchronous
/// in-process call could not self-impose). The output pixmap area/height are
/// separately capped inside [`crate::rasterize`] ([`crate::MAX_OUTPUT_MEGAPIXELS`]
/// + [`RenderParams::max_height_px`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderLimits {
/// Maximum accepted source length in bytes. Larger input is rejected with
/// [`MermaidError::Unsupported`] *before* the engine runs.
pub max_source_bytes: usize,
}
impl Default for RenderLimits {
fn default() -> Self {
// 64 KiB — comfortably larger than any hand-authored diagram.
Self {
max_source_bytes: 64 * 1024,
}
}
}
/// A pluggable Mermaid rendering backend.
///
/// Implementations turn Mermaid source into a rasterized [`RenderedDiagram`].
/// Prefer calling [`render_checked`] over this method directly: it applies
/// [`RenderLimits`] and isolates panics. Implementations must be cheap to share
/// (`Send + Sync`) so a worker pool can hold one behind an `Arc`.
pub trait MermaidEngine: Send + Sync {
/// Render `source` to a PNG using `params`.
///
/// Implementations may panic on pathological input; callers are expected to
/// wrap this via [`render_checked`].
fn render(&self, source: &str, params: &RenderParams) -> Result<RenderedDiagram, MermaidError>;
}
/// Render `source` with `engine`, enforcing `limits` and isolating panics.
///
/// This is the entry point a caller (e.g. a render worker) should use over
/// [`MermaidEngine::render`]:
///
/// - Source larger than [`RenderLimits::max_source_bytes`] is rejected with
/// [`MermaidError::Unsupported`] **without invoking the engine**.
/// - An engine panic is caught and returned as [`MermaidError::Panic`].
///
/// # Panic isolation is conditional on the unwind strategy
///
/// `catch_unwind` only intercepts panics under `panic = "unwind"`. The shipped
/// Release CLI profiles build with `panic = "abort"`,
/// under which a panicking engine aborts the **whole process** and this guard is
/// a no-op. True crash-isolation over untrusted source therefore comes from
/// running the engine *out of process*: the pager spawns a short-lived child per
/// diagram (see [`crate::run_with_timeout`] and the pager's `mermaid_worker`), so
/// a child abort is contained and the timeout is a real process kill. Within a
/// single process this guard still upgrades a (test/unwind-profile) panic to a
/// clean error. `catch_unwind` cannot catch aborts from stack overflow or
/// allocation failure even under unwind.
pub fn render_checked(
engine: &dyn MermaidEngine,
source: &str,
params: &RenderParams,
limits: &RenderLimits,
) -> Result<RenderedDiagram, MermaidError> {
if source.len() > limits.max_source_bytes {
return Err(MermaidError::Unsupported(format!(
"source is {} bytes, over the {}-byte limit",
source.len(),
limits.max_source_bytes
)));
}
// AssertUnwindSafe: a panic aborts this render and is converted to an error;
// no observable state from the engine is reused afterward.
match std::panic::catch_unwind(AssertUnwindSafe(|| engine.render(source, params))) {
Ok(result) => result,
Err(payload) => {
let msg = panic_message(payload);
// The panic message can embed untrusted source fragments, so keep its
// content out of the default `warn` stream and behind `debug`.
tracing::warn!(target: "mermaid", panic_len = msg.len(), "engine panicked; converted to error");
tracing::debug!(target: "mermaid", panic = %msg, "engine panic message");
Err(MermaidError::Panic(msg))
}
}
}
/// Best-effort extraction of a human-readable message from a panic payload.
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"engine panicked".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Records whether `render` was invoked, to prove the early limit check
/// short-circuits before the engine runs.
struct SpyEngine {
called: std::sync::atomic::AtomicBool,
outcome: fn() -> Result<RenderedDiagram, MermaidError>,
}
impl MermaidEngine for SpyEngine {
fn render(
&self,
_source: &str,
_params: &RenderParams,
) -> Result<RenderedDiagram, MermaidError> {
self.called.store(true, std::sync::atomic::Ordering::SeqCst);
(self.outcome)()
}
}
fn ok_diagram() -> Result<RenderedDiagram, MermaidError> {
Ok(RenderedDiagram {
png: vec![1, 2, 3],
width_px: 10,
height_px: 20,
})
}
#[test]
fn passes_through_engine_success() {
let engine = SpyEngine {
called: Default::default(),
outcome: ok_diagram,
};
let out = render_checked(
&engine,
"flowchart LR; A-->B",
&RenderParams::default(),
&RenderLimits::default(),
)
.expect("should succeed");
assert_eq!(out.width_px, 10);
assert!(engine.called.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn oversized_source_rejected_before_engine_runs() {
let engine = SpyEngine {
called: Default::default(),
// If the engine were ever called for oversized input, this panic
// would surface as `Panic`, not `Unsupported`, failing the assert.
outcome: || panic!("engine must not be called for oversized source"),
};
let limits = RenderLimits {
max_source_bytes: 8,
};
let err = render_checked(
&engine,
"this source is definitely longer than eight bytes",
&RenderParams::default(),
&limits,
)
.expect_err("oversized source must be rejected");
assert!(matches!(err, MermaidError::Unsupported(_)));
assert!(
!engine.called.load(std::sync::atomic::Ordering::SeqCst),
"engine must not run when the source is over the limit"
);
}
#[test]
fn source_at_limit_is_accepted() {
let engine = SpyEngine {
called: Default::default(),
outcome: ok_diagram,
};
let src = "12345678"; // exactly 8 bytes
let limits = RenderLimits {
max_source_bytes: 8,
};
assert!(render_checked(&engine, src, &RenderParams::default(), &limits).is_ok());
}
#[test]
fn panicking_engine_becomes_panic_error() {
struct Panicky;
impl MermaidEngine for Panicky {
fn render(&self, _: &str, _: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
panic!("boom in layout");
}
}
let err = render_checked(
&Panicky,
"flowchart LR; A-->B",
&RenderParams::default(),
&RenderLimits::default(),
)
.expect_err("panic must be converted to an error");
match err {
MermaidError::Panic(msg) => assert!(msg.contains("boom in layout")),
other => panic!("expected Panic, got {other:?}"),
}
}
#[test]
fn engine_errors_pass_through_unchanged() {
struct Failing(fn() -> MermaidError);
impl MermaidEngine for Failing {
fn render(&self, _: &str, _: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
Err((self.0)())
}
}
// The wrapper must return the exact same variant *and* payload the engine
// produced — not merely "not Panic".
for make in [
(|| MermaidError::Parse("p-payload".into())) as fn() -> MermaidError,
|| MermaidError::Layout("l-payload".into()),
|| MermaidError::Rasterize("r-payload".into()),
|| MermaidError::Timeout,
|| MermaidError::Unsupported("u-payload".into()),
] {
let injected = make();
let err = render_checked(
&Failing(make),
"x",
&RenderParams::default(),
&RenderLimits::default(),
)
.expect_err("engine error should pass through");
assert_eq!(
std::mem::discriminant(&err),
std::mem::discriminant(&injected),
"variant changed: got {err:?}, expected {injected:?}"
);
// Payload round-trips verbatim (Display embeds the carried string).
assert_eq!(err.to_string(), injected.to_string());
}
}
#[test]
fn error_display_is_descriptive() {
// Each variant's Display carries a distinguishing word, and payload
// variants interpolate their carried message.
assert!(MermaidError::Timeout.to_string().contains("timed out"));
for (err, word) in [
(MermaidError::Parse("PL".into()), "parse"),
(MermaidError::Layout("PL".into()), "layout"),
(MermaidError::Rasterize("PL".into()), "rasterize"),
(MermaidError::Unsupported("PL".into()), "unsupported"),
(MermaidError::Panic("PL".into()), "panicked"),
] {
let s = err.to_string();
assert!(s.contains(word), "{s:?} missing {word:?}");
assert!(s.contains("PL"), "{s:?} missing interpolated payload");
}
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Render [Mermaid](https://mermaid.js.org/) diagram source to a rasterized PNG,
//! behind a swappable [`MermaidEngine`] trait.
//!
//! This crate is a self-contained, pure-library building block: it turns Mermaid
//! diagram text into PNG bytes with no Node, no headless browser, and no network.
//! It isolates the layout engine and the SVG raster stack behind our own audited
//! boundary so the rest of the CLI can swap engines or fall back to a code block
//! without caring how a diagram is produced.
//!
//! # Pipeline
//!
//! 1. A [`MermaidEngine`] turns Mermaid source into an SVG and rasterizes it.
//! The default engine ([`PureRustEngine`]) uses the vendored, dagre-based
//! `mermaid-to-svg` for layout, then [`rasterize`].
//! 2. [`rasterize`] converts SVG to PNG with `resvg`/`usvg`/`tiny-skia`,
//! configured with **no remote/file resolvers** and a **bundled font** so it
//! is safe over untrusted input and deterministic across machines.
//!
//! # Untrusted input and crash isolation
//!
//! Because Mermaid source is untrusted model/tool output, call
//! [`render_checked`] rather than [`MermaidEngine::render`] directly: it enforces
//! a source-size limit and converts an engine panic into a [`MermaidError`].
//! `catch_unwind` only intercepts panics under `panic = "unwind"`; the shipped
//! CLI profiles build with `panic = "abort"`, where a panicking engine aborts
//! the process. The real crash isolation is therefore **out of process**: the
//! pager renders each diagram in a short-lived child process (see
//! [`run_with_timeout`] and the pager's `mermaid_worker`), so a panic or runaway
//! render is contained to the child and the wall-clock timeout is a real,
//! killable process kill. This crate provides both the in-process engine and the
//! subprocess spawn/timeout/reap building blocks that child uses.
//!
//! # Example
//!
//! ```
//! use kigi_mermaid::{default_engine, render_checked, RenderLimits, RenderParams};
//!
//! let engine = default_engine();
//! let params = RenderParams::default();
//! let result = render_checked(engine.as_ref(), "flowchart LR\nA-->B", &params, &RenderLimits::default());
//! let diagram = result.expect("a simple flowchart renders");
//! assert!(diagram.width_px > 0 && diagram.height_px > 0);
//! ```
#![warn(missing_docs)]
mod engine;
mod mmdc;
mod pure;
mod raster;
mod subprocess;
pub use engine::{MermaidEngine, MermaidError, RenderLimits, render_checked};
pub use mmdc::{MmdcEngine, detect_mmdc};
pub use pure::PureRustEngine;
pub use raster::{MAX_OUTPUT_MEGAPIXELS, rasterize};
pub use subprocess::{SubprocessError, run_with_timeout};
use std::sync::Arc;
/// Which color scheme a diagram should be rendered for.
///
/// Mapped from the pager's theme by the caller; only the light/dark split is
/// relevant to diagram rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MermaidTheme {
/// Light surfaces with dark text (e.g. `GrokDay`).
#[default]
Light,
/// Dark surfaces with light text (e.g. `GrokNight`, `TokyoNight`).
Dark,
}
/// Default opaque surface colors. Single source of truth, shared by the raster
/// background ([`MermaidTheme::surface_background`]) and the vendored engine's
/// theme background (`pure::theme_for`, via [`Rgba::to_hex`]).
pub(crate) const LIGHT_SURFACE: Rgba = Rgba::new(0xFA, 0xFA, 0xFA, 0xFF);
pub(crate) const DARK_SURFACE: Rgba = Rgba::new(0x18, 0x18, 0x1B, 0xFF);
impl MermaidTheme {
/// The default opaque surface color a diagram blends into for this theme.
///
/// Used as the raster background when the caller does not supply an explicit
/// [`RenderParams::background`]; chosen to approximate a typical terminal
/// scrollback surface so the PNG sits flush with the grid.
pub fn surface_background(self) -> Rgba {
match self {
MermaidTheme::Light => LIGHT_SURFACE,
MermaidTheme::Dark => DARK_SURFACE,
}
}
}
/// A straight 8-bit-per-channel, non-premultiplied RGBA color.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgba {
/// Red channel, 0255.
pub r: u8,
/// Green channel, 0255.
pub g: u8,
/// Blue channel, 0255.
pub b: u8,
/// Alpha channel, 0 (transparent) 255 (opaque).
pub a: u8,
}
impl Rgba {
/// Construct an [`Rgba`] from its four channels.
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// Format as an opaque `#RRGGBB` hex string (alpha is ignored).
pub fn to_hex(self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
}
/// Parameters controlling a single diagram render.
///
/// Mirrors the sizing model: [`target_width_px`](Self::target_width_px)
/// is the primary size driver (already HiDPI-oversampled by the caller),
/// [`max_height_px`](Self::max_height_px) clamps tall diagrams, and
/// [`scale`](Self::scale) is the fallback oversample used only when
/// `target_width_px == 0`. [`min_width_px`](Self::min_width_px) raises the scale
/// so small diagrams still rasterize wide enough for OS viewers. The default
/// config is **target-width-driven** (`target_width_px` non-zero), so the default
/// `scale` is inert.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RenderParams {
/// Color scheme to render for.
pub theme: MermaidTheme,
/// Target output width in pixels. When non-zero this drives the output size
/// (the SVG is scaled so its width matches). `0` falls back to [`scale`](Self::scale).
pub target_width_px: u32,
/// Hard ceiling on output height in pixels; the render is scaled down to fit.
/// `0` disables the height clamp (output area is still bounded by
/// [`MAX_OUTPUT_MEGAPIXELS`]).
pub max_height_px: u32,
/// Oversample factor applied **only** when `target_width_px == 0`; inert
/// otherwise (the default config is target-width-driven, see the struct doc).
pub scale: f32,
/// Minimum output width in pixels. When non-zero, scale is raised so the
/// raster is at least this wide (before height / megapixel clamps). Useful
/// for OS-viewer opens of small diagrams. `0` disables.
pub min_width_px: u32,
/// Opaque background fill. `None` renders on a transparent background so the
/// terminal cell color shows through.
pub background: Option<Rgba>,
}
impl Default for RenderParams {
fn default() -> Self {
Self {
theme: MermaidTheme::Light,
target_width_px: 1024,
max_height_px: 4096,
// Inert by default (target_width_px drives sizing); 1.0 so a caller
// that zeroes target_width_px without touching scale gets 1:1.
scale: 1.0,
min_width_px: 0,
background: None,
}
}
}
impl RenderParams {
/// Sizing tuned for opening a PNG in an OS image viewer: prefer 2× the SVG's
/// intrinsic size, ensure at least `min_width_px` width for small diagrams,
/// and allow a taller canvas than the terminal-budget path. Height and the
/// crate-wide megapixel/axis caps still apply.
pub fn for_os_viewer(theme: MermaidTheme, min_width_px: u32, max_height_px: u32) -> Self {
Self {
theme,
// Drive from `scale` + `min_width_px` so large SVGs keep aspect at 2×
// and small SVGs are upscaled to a readable minimum width.
target_width_px: 0,
max_height_px,
scale: 2.0,
min_width_px,
background: Some(theme.surface_background()),
}
}
}
/// A rendered diagram: PNG bytes plus the exact raster dimensions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedDiagram {
/// The encoded PNG image.
pub png: Vec<u8>,
/// Output width in pixels.
pub width_px: u32,
/// Output height in pixels.
pub height_px: u32,
}
/// Construct the default engine: the offline, pure-Rust [`PureRustEngine`].
///
/// `mmdc` is never selected automatically — construct [`MmdcEngine`] explicitly
/// to opt in.
pub fn default_engine() -> Arc<dyn MermaidEngine> {
Arc::new(PureRustEngine::new())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_surface_background_differs_light_vs_dark() {
let light = MermaidTheme::Light.surface_background();
let dark = MermaidTheme::Dark.surface_background();
assert_ne!(light, dark, "light and dark must map to different surfaces");
// Light surface is brighter than dark on every channel; both opaque.
assert!(light.r > dark.r && light.g > dark.g && light.b > dark.b);
assert_eq!(light.a, 0xFF);
assert_eq!(dark.a, 0xFF);
}
#[test]
fn rgba_to_hex_is_opaque_rrggbb() {
assert_eq!(Rgba::new(0x12, 0xAB, 0xCD, 0xFF).to_hex(), "#12ABCD");
// Alpha is ignored.
assert_eq!(Rgba::new(0, 0, 0, 0).to_hex(), "#000000");
// The shared dark surface const renders to the hex the dark theme uses.
assert_eq!(DARK_SURFACE.to_hex(), "#18181B");
}
#[test]
fn default_params_are_target_width_driven() {
// Exercises the real default path: target_width_px (1024) drives output
// width regardless of `scale`. Engine-agnostic, runs in the default build.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><rect width="10" height="10" fill="#0000ff"/></svg>"##;
let out = rasterize(svg, &RenderParams::default()).expect("render");
assert_eq!(
out.width_px, 1024,
"default target_width_px should drive width"
);
}
#[test]
fn default_engine_is_constructible_and_send_sync() {
fn assert_send_sync<T: Send + Sync>(_: &T) {}
let engine = default_engine();
assert_send_sync(&engine);
}
/// The default engine renders a real PNG and never panics on valid input.
#[test]
fn default_engine_renders_valid_input() {
let engine = default_engine();
let diagram = render_checked(
engine.as_ref(),
"flowchart LR\nA-->B",
&RenderParams::default(),
&RenderLimits::default(),
)
.expect("the default engine should render");
assert!(diagram.width_px > 0 && diagram.height_px > 0);
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Optional `mmdc` (mermaid-cli) engine, detected at runtime.
//!
//! High-fidelity but heavy (Node + headless Chromium), so it is never selected
//! automatically — a caller opts in via [`MmdcEngine::detect`] / [`MmdcEngine::new`].
//! `mmdc` produces the SVG; we rasterize it through [`crate::rasterize`] so the
//! same security posture (no file resolvers, bundled font) and sizing apply.
//!
//! Security: the subprocess is spawned with [`kigi_tty_utils::detach_std_command`]
//! (TTY/session detach) + [`kigi_tty_utils::pager_env`] + null stdio, source is
//! passed via a private temp file, and the shared [`crate::run_with_timeout`]
//! enforces a wall-clock budget and reaps the process group (including Chromium
//! grandchildren) on breach.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::subprocess::{SubprocessError, run_with_timeout};
use crate::{MermaidEngine, MermaidError, MermaidTheme, RenderParams, RenderedDiagram};
/// Default wall-clock budget for an `mmdc` invocation.
pub const DEFAULT_MMDC_TIMEOUT: Duration = Duration::from_millis(1500);
/// Locate the `mmdc` binary on `PATH`, if installed.
pub fn detect_mmdc() -> Option<PathBuf> {
which::which("mmdc").ok()
}
/// An engine that shells out to `mmdc` (mermaid-cli).
///
/// Off by default: construct it explicitly (it requires Node + headless
/// Chromium). Use [`MmdcEngine::detect`] to build one only if `mmdc` is present.
pub struct MmdcEngine {
bin: PathBuf,
timeout: Duration,
}
impl MmdcEngine {
/// Build an engine that runs the `mmdc` binary at `bin`.
pub fn new(bin: PathBuf) -> Self {
Self {
bin,
timeout: DEFAULT_MMDC_TIMEOUT,
}
}
/// Build an engine if (and only if) `mmdc` is found on `PATH`.
pub fn detect() -> Option<Self> {
detect_mmdc().map(Self::new)
}
/// Override the wall-clock timeout (default [`DEFAULT_MMDC_TIMEOUT`]).
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// The resolved `mmdc` binary path.
pub fn binary(&self) -> &Path {
&self.bin
}
}
impl MermaidEngine for MmdcEngine {
fn render(&self, source: &str, params: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
// Environment/IO failures below are `Rasterize` (a render-pipeline
// failure), not `Unsupported` (which connotes "this input/engine isn't
// supported"); spawn failure stays `Unsupported` (engine unavailable).
let dir = tempfile::Builder::new()
.prefix("xai-mermaid-")
.tempdir()
.map_err(|e| MermaidError::Rasterize(format!("could not create temp dir: {e}")))?;
let input = dir.path().join("diagram.mmd");
let output = dir.path().join("diagram.svg");
// Create atomically with 0600 (no umask/chmod TOCTOU window). The parent
// tempdir is already 0700.
write_private(&input, source)
.map_err(|e| MermaidError::Rasterize(format!("could not write source: {e}")))?;
let mut cmd = Command::new(&self.bin);
cmd.arg("--input")
.arg(&input)
.arg("--output")
.arg(&output)
.arg("--outputFormat")
.arg("svg")
.arg("--theme")
.arg(theme_arg(params.theme))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.envs(kigi_tty_utils::pager_env());
// setsid/console detach via the sanctioned helper (never a raw pre_exec).
kigi_tty_utils::detach_std_command(&mut cmd);
// Source goes via the temp file, so no stdin payload.
run_with_timeout(cmd, None, self.timeout).map_err(map_subprocess_error)?;
let svg = std::fs::read_to_string(&output).map_err(|e| {
MermaidError::Layout(format!("mmdc produced no readable SVG output: {e}"))
})?;
crate::rasterize(&svg, params)
}
}
fn theme_arg(theme: MermaidTheme) -> &'static str {
match theme {
MermaidTheme::Light => "default",
MermaidTheme::Dark => "dark",
}
}
/// Map a subprocess failure onto the engine error taxonomy: a spawn failure
/// means `mmdc` is unavailable ([`MermaidError::Unsupported`]); a non-zero exit
/// is a render/layout failure; a wait failure is a pipeline ([`Rasterize`]) error.
///
/// [`Rasterize`]: MermaidError::Rasterize
fn map_subprocess_error(e: SubprocessError) -> MermaidError {
match e {
SubprocessError::Spawn(e) => {
MermaidError::Unsupported(format!("could not spawn mmdc: {e}"))
}
SubprocessError::Timeout => MermaidError::Timeout,
SubprocessError::NonZeroExit(status) => {
MermaidError::Layout(format!("mmdc exited with {status}"))
}
SubprocessError::Wait(e) => MermaidError::Rasterize(format!("mmdc wait failed: {e}")),
}
}
/// Write `contents` to `path`, creating it atomically with owner-only (0600)
/// permissions on unix so there is no umask/chmod TOCTOU window.
fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
/// render() invokes `mmdc --input $2 --output $4 --outputFormat svg --theme $8`,
/// so the script reads `$4` as the output path.
#[cfg(unix)]
fn fake_mmdc(body: &str) -> (tempfile::TempDir, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("mmdc");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
(dir, path)
}
#[test]
fn theme_arg_maps_light_and_dark() {
assert_eq!(theme_arg(MermaidTheme::Light), "default");
assert_eq!(theme_arg(MermaidTheme::Dark), "dark");
}
#[test]
fn binary_accessor_round_trips() {
let p = PathBuf::from("/some/path/to/mmdc");
assert_eq!(MmdcEngine::new(p.clone()).binary(), p.as_path());
}
#[test]
fn map_subprocess_error_preserves_taxonomy() {
assert!(matches!(
map_subprocess_error(SubprocessError::Spawn(std::io::Error::other("x"))),
MermaidError::Unsupported(_)
));
assert!(matches!(
map_subprocess_error(SubprocessError::Timeout),
MermaidError::Timeout
));
assert!(matches!(
map_subprocess_error(SubprocessError::Wait(std::io::Error::other("x"))),
MermaidError::Rasterize(_)
));
}
#[test]
fn missing_binary_is_unsupported() {
let engine = MmdcEngine::new(PathBuf::from("definitely-not-a-real-binary-9f8a7b6c5d4e"));
let err = engine
.render("flowchart LR; A-->B", &RenderParams::default())
.expect_err("spawning a missing binary must fail");
assert!(matches!(err, MermaidError::Unsupported(_)));
}
#[cfg(unix)]
#[test]
fn fake_mmdc_success_produces_decodable_png() {
let (_dir, bin) = fake_mmdc(
r##"printf '%s' '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#445566"/></svg>' > "$4""##,
);
let out = MmdcEngine::new(bin)
.render("flowchart LR; A-->B", &RenderParams::default())
.expect("fake mmdc output should rasterize");
assert!(out.width_px > 0 && out.height_px > 0);
assert!(image::load_from_memory(&out.png).is_ok());
}
#[cfg(unix)]
#[test]
fn fake_mmdc_zero_exit_without_output_is_layout_error() {
// Exits 0 but writes nothing → the "no readable SVG output" Layout error.
let (_dir, bin) = fake_mmdc("exit 0");
let err = MmdcEngine::new(bin)
.render("flowchart LR; A-->B", &RenderParams::default())
.expect_err("missing output must error");
assert!(matches!(err, MermaidError::Layout(_)), "got {err:?}");
}
#[cfg(unix)]
#[test]
fn fake_mmdc_nonzero_exit_maps_to_layout() {
// A non-zero exit from mmdc surfaces as a Layout error, distinct from a
// timeout or a missing binary.
let (_dir, bin) = fake_mmdc("exit 3");
let err = MmdcEngine::new(bin)
.render("flowchart LR; A-->B", &RenderParams::default())
.expect_err("non-zero exit must error");
assert!(matches!(err, MermaidError::Layout(_)), "got {err:?}");
}
#[cfg(unix)]
#[test]
fn with_timeout_is_honored() {
// A fake that sleeps far longer than the configured timeout must time out
// (and be reaped) quickly — proving with_timeout feeds run_with_timeout.
let (_dir, bin) = fake_mmdc("sleep 30");
let start = Instant::now();
let err = MmdcEngine::new(bin)
.with_timeout(Duration::from_millis(100))
.render("flowchart LR; A-->B", &RenderParams::default())
.expect_err("should time out");
assert!(matches!(err, MermaidError::Timeout));
assert!(start.elapsed() < Duration::from_secs(2));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Pure-Rust engine: Mermaid source -> SVG via the vendored `mermaid-to-svg`
//! (a dagre layout port), then [`crate::rasterize`] to PNG.
use mermaid_to_svg::{MermaidTheme as EngineTheme, render_mermaid_to_svg};
use crate::{MermaidEngine, MermaidError, MermaidTheme, RenderParams, RenderedDiagram};
/// The default, offline, pure-Rust engine.
///
/// Uses the vendored dagre-based layout engine to produce an SVG, then
/// rasterizes it with the crate's hardened [`crate::rasterize`] pipeline.
#[derive(Debug, Default, Clone, Copy)]
pub struct PureRustEngine;
impl PureRustEngine {
/// Construct a [`PureRustEngine`].
pub fn new() -> Self {
Self
}
}
impl MermaidEngine for PureRustEngine {
fn render(&self, source: &str, params: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
let svg = build_svg(source, params.theme)?;
crate::rasterize(&svg, params)
}
}
/// Mermaid source -> SVG (the layout half). A free function (no engine state) so
/// the SVG can be tested directly and reused by [`MermaidEngine::render`].
///
/// The engine returns an error for unparseable or unsupported diagram types; the
/// caller degrades any error to the code-block fallback (see
/// [`crate::render_checked`]).
fn build_svg(source: &str, theme: MermaidTheme) -> Result<String, MermaidError> {
let engine_theme = theme_for(theme);
render_mermaid_to_svg(source, Some(&engine_theme)).map_err(map_engine_error)
}
/// Map the vendored engine's error taxonomy onto ours, preserving the
/// parse/layout/unsupported split so observability stays honest.
fn map_engine_error(e: mermaid_to_svg::MermaidError) -> MermaidError {
use mermaid_to_svg::MermaidError as E;
match e {
E::ParseError { .. } | E::InvalidDirection(_) | E::InvalidNodeShape(_) => {
MermaidError::Parse(e.to_string())
}
E::DotGenerationError(_) | E::RenderError(_) => MermaidError::Layout(e.to_string()),
E::UnsupportedDiagramType(_) => MermaidError::Unsupported(e.to_string()),
}
}
/// Map [`MermaidTheme`] to a vendored-engine [`EngineTheme`].
///
/// Only the diagram surface is overridden, to the crate's single-source-of-truth
/// surface color ([`crate::LIGHT_SURFACE`] / [`crate::DARK_SURFACE`]) so the
/// painted SVG background blends with the terminal scrollback surface the PNG
/// sits on; the rest of each preset's palette is used as-is.
fn theme_for(theme: MermaidTheme) -> EngineTheme {
match theme {
MermaidTheme::Light => {
let mut t = EngineTheme::light();
t.background = crate::LIGHT_SURFACE.to_hex();
t
}
MermaidTheme::Dark => {
let mut t = EngineTheme::dark();
t.background = crate::DARK_SURFACE.to_hex();
t
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RenderLimits, render_checked};
#[test]
fn flowchart_svg_contains_node_labels() {
let svg = build_svg(
"flowchart LR\n A[Start] --> B[Finish]",
MermaidTheme::Light,
)
.expect("flowchart should render to svg");
assert!(svg.contains("<svg"), "must be an svg document");
assert!(svg.contains("</svg>"));
assert!(svg.contains("Start"), "node label 'Start' missing from svg");
assert!(
svg.contains("Finish"),
"node label 'Finish' missing from svg"
);
}
#[test]
fn sequence_svg_contains_participants() {
let svg = build_svg(
"sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi",
MermaidTheme::Light,
)
.expect("sequence should render");
assert!(svg.contains("Alice"));
assert!(svg.contains("Bob"));
}
#[test]
fn render_produces_decodable_png_with_matching_dims() {
let out = PureRustEngine::new()
.render("flowchart LR\nA-->B-->C", &RenderParams::default())
.expect("render should succeed");
assert!(out.width_px > 0 && out.height_px > 0);
let img = image::load_from_memory(&out.png).expect("output must be a valid png");
assert_eq!(img.width(), out.width_px);
assert_eq!(img.height(), out.height_px);
}
#[test]
fn render_is_deterministic_in_process() {
// The engine measures text with fixed char-width metrics (no system-font
// dependence), so the same source+params reproduce identical bytes.
let engine = PureRustEngine::new();
let p = RenderParams::default();
let a = engine.render("flowchart LR\nA-->B-->C", &p).expect("a");
let b = engine.render("flowchart LR\nA-->B-->C", &p).expect("b");
assert_eq!(
a.png, b.png,
"same source+params must yield identical png within a process"
);
}
/// A cyclic flowchart whose back-edge (`Attempts -->|No| Enter`) routes back
/// up into the cycle — the tricky case for flowchart edge routing. Every one
/// of the eight edges must keep its arrowhead, and no node may be dropped by
/// the cycle.
#[test]
fn cyclic_login_flow_renders_with_arrowheads() {
// Eight directed edges; each must emit exactly one arrowhead marker.
const EDGE_COUNT: usize = 8;
let source = "flowchart TD\n\
Start([User visits login page]) --> Enter[Enter username & password]\n\
Enter --> Submit[Submit credentials]\n\
Submit --> Validate{Credentials valid?}\n\
Validate -->|No| Fail[Show error message]\n\
Fail --> Attempts{Too many failed attempts?}\n\
Attempts -->|Yes| Lock[Lock account]\n\
Attempts -->|No| Enter\n\
Validate -->|Yes| Session[Create session]";
let svg = build_svg(source, MermaidTheme::Light).expect("cyclic flow renders");
// Pin the invariant to the edges: exactly one `marker-end="url(#arrowhead)"`
// per edge, so a dropped/detached back-edge arrowhead fails (a whole-doc
// "contains arrow" substring check would pass even with one missing).
let arrowheads = svg.matches(r#"marker-end="url(#arrowhead)""#).count();
assert_eq!(
arrowheads, EDGE_COUNT,
"every flowchart edge must carry an arrowhead marker",
);
// All node labels survive layout (no node dropped by the cycle).
for label in [
"Enter username",
"Submit credentials",
"Credentials valid",
"Too many failed attempts",
"Lock account",
"Create session",
] {
assert!(svg.contains(label), "missing node label {label:?}");
}
}
#[test]
fn light_and_dark_render_to_different_pixels() {
// Stronger than an SVG-string diff: render both themes at identical
// params and assert the encoded pixels actually differ.
let engine = PureRustEngine::new();
let light = engine
.render(
"flowchart LR\nA-->B",
&RenderParams {
theme: MermaidTheme::Light,
..Default::default()
},
)
.expect("light");
let dark = engine
.render(
"flowchart LR\nA-->B",
&RenderParams {
theme: MermaidTheme::Dark,
..Default::default()
},
)
.expect("dark");
assert_eq!(
(light.width_px, light.height_px),
(dark.width_px, dark.height_px),
"same params must yield the same dimensions"
);
assert_ne!(
light.png, dark.png,
"themes must change the rendered pixels"
);
}
#[test]
fn theme_for_overrides_surface_per_theme() {
// The diagram background is the crate's surface single-source-of-truth so
// the PNG blends with the terminal scrollback surface.
assert_eq!(
theme_for(MermaidTheme::Light).background,
crate::LIGHT_SURFACE.to_hex()
);
assert_eq!(
theme_for(MermaidTheme::Dark).background,
crate::DARK_SURFACE.to_hex()
);
assert_ne!(
theme_for(MermaidTheme::Light).background,
theme_for(MermaidTheme::Dark).background,
);
}
/// Untrusted input must never panic — `render_checked` would surface a panic
/// as `MermaidError::Panic`, which we assert against. Unparseable input may
/// legitimately return other errors (which degrade to the code-block
/// fallback), but never a panic.
#[test]
fn garbage_input_never_panics() {
let engine = PureRustEngine::new();
let limits = RenderLimits::default();
let params = RenderParams::default();
for garbage in [
"",
"@@@@",
"%% only a comment",
"flowchart\n\n\n",
"????????",
"\u{0}\u{1}\u{2}\u{3}",
"flowchart LR\n A[unterminated --> ",
"pie\n : :",
"erDiagram\n A ||",
"sequenceDiagram\n A->>",
] {
let out = render_checked(&engine, garbage, &params, &limits);
assert!(
!matches!(out, Err(MermaidError::Panic(_))),
"engine panicked on {garbage:?}: {out:?}"
);
}
}
#[test]
fn engine_error_taxonomy_maps_every_arm() {
use mermaid_to_svg::MermaidError as E;
// Parse family: malformed source, bad direction, bad node shape.
for parse in [
E::ParseError {
line: 1,
message: "x".into(),
},
E::InvalidDirection("x".into()),
E::InvalidNodeShape("x".into()),
] {
assert!(
matches!(map_engine_error(parse), MermaidError::Parse(_)),
"expected Parse mapping",
);
}
// Layout family: dot generation + SVG render failures.
for layout in [
E::DotGenerationError("x".into()),
E::RenderError("x".into()),
] {
assert!(
matches!(map_engine_error(layout), MermaidError::Layout(_)),
"expected Layout mapping",
);
}
// Unsupported diagram type is its own category.
assert!(matches!(
map_engine_error(E::UnsupportedDiagramType("x".into())),
MermaidError::Unsupported(_)
));
}
}
+580
View File
@@ -0,0 +1,580 @@
//! SVG -> PNG rasterization via `resvg`/`usvg`/`tiny-skia`.
//!
//! Configured for untrusted input: a bundled-first font database (shaping is
//! pinned to the bundled face; system fonts serve only as glyph fallback for
//! non-ASCII text) and no external image resolvers (no
//! `file://`/`http://`/local-path reads). PNG is produced by tiny-skia's own
//! encoder — the `image` crate's codecs are not used on the render path.
use std::sync::Arc;
use std::sync::OnceLock;
// usvg/tiny_skia come from `resvg`'s re-exports so their versions match
// `resvg::render` exactly (the workspace `usvg` pin is an older, incompatible
// line). `tiny_skia` is the workspace dep, which resolves to the same version
// `resvg` links, so the types unify.
use resvg::usvg;
use crate::{MermaidError, RenderParams, RenderedDiagram, Rgba};
/// Bundled primary sans face (Roboto Regular, Apache-2.0); system fonts are
/// consulted only as glyph fallback for characters it lacks.
///
/// The vendored layout engine measures text with fixed char-width metrics (no
/// font file), so there is no layout/raster font to keep in sync — this face is
/// used purely to rasterize glyphs.
pub(crate) const BUNDLED_FONT: &[u8] = include_bytes!("../assets/Roboto-Regular.ttf");
/// Hard ceiling on output area, applied regardless of requested size, to bound
/// memory over untrusted/huge diagrams (32 MP ≈ a 5657×5657 image).
pub const MAX_OUTPUT_MEGAPIXELS: f32 = 32.0;
/// Hard ceiling on either output axis, so an extreme-aspect diagram can't pin
/// one dimension to a huge value even when the area cap leaves headroom.
const MAX_OUTPUT_DIMENSION: u32 = 16_384;
struct FontSet {
db: Arc<fontdb::Database>,
family: String,
bundled_id: fontdb::ID,
}
fn build_font_set(with_system_fonts: bool) -> FontSet {
let mut db = fontdb::Database::new();
db.load_font_data(BUNDLED_FONT.to_vec());
let (bundled_id, family) = db
.faces()
.next()
.map(|face| {
(
face.id,
face.families
.first()
.map(|(name, _)| name.clone())
.unwrap_or_else(|| "sans-serif".to_string()),
)
})
.expect("the bundled font must parse to at least one face");
if with_system_fonts {
db.load_system_fonts();
}
// The engine emits font-family lists ending in a generic (e.g.
// "Inter, …, sans-serif"). None of the named families are loaded, so
// resolution falls to the generic — which fontdb maps to its default
// name (Arial/Times/…), also not loaded. With system fonts disabled
// that drops every glyph (blank node labels). Point all generics at the
// one bundled face so any list resolves to Roboto.
db.set_serif_family(&family);
db.set_sans_serif_family(&family);
db.set_monospace_family(&family);
db.set_cursive_family(&family);
db.set_fantasy_family(&family);
FontSet {
db: Arc::new(db),
family,
bundled_id,
}
}
/// Parse and index the bundled font once; the `Arc` is shared into every
/// `Options` per render with an O(1) refcount bump (no per-render clone).
fn bundled_font() -> &'static FontSet {
static FONT: OnceLock<FontSet> = OnceLock::new();
FONT.get_or_init(|| build_font_set(false))
}
fn font_with_system_fallback() -> &'static FontSet {
static FONT: OnceLock<FontSet> = OnceLock::new();
FONT.get_or_init(|| build_font_set(true))
}
fn font_set_for(svg: &str) -> &'static FontSet {
if svg.is_ascii() {
bundled_font()
} else {
font_with_system_fallback()
}
}
fn pinned_resolver(bundled_id: fontdb::ID) -> usvg::FontResolver<'static> {
usvg::FontResolver {
select_font: Box::new(move |_font, _db| Some(bundled_id)),
select_fallback: usvg::FontResolver::default_fallback_selector(),
}
}
fn rgba_to_color(c: Rgba) -> tiny_skia::Color {
tiny_skia::Color::from_rgba8(c.r, c.g, c.b, c.a)
}
/// Rasterize `svg` to a PNG using `params`.
///
/// Sizing: the SVG's intrinsic size is scaled to [`RenderParams::target_width_px`]
/// (or by [`RenderParams::scale`] when that is `0`), raised to meet
/// [`RenderParams::min_width_px`] when set, then clamped down so the output fits
/// [`RenderParams::max_height_px`], stays within [`MAX_OUTPUT_MEGAPIXELS`] total
/// area, and has neither axis larger than the internal per-axis cap. A
/// [`RenderParams::background`] of `Some` fills the canvas opaquely; `None`
/// leaves it transparent.
///
/// # Errors
///
/// Returns [`MermaidError::Rasterize`] if the SVG cannot be parsed, has zero
/// size, or cannot be encoded to PNG.
pub fn rasterize(svg: &str, params: &RenderParams) -> Result<RenderedDiagram, MermaidError> {
rasterize_with_font(svg, params, font_set_for(svg))
}
fn rasterize_with_font(
svg: &str,
params: &RenderParams,
font: &FontSet,
) -> Result<RenderedDiagram, MermaidError> {
let mut opt = usvg::Options {
fontdb: Arc::clone(&font.db),
font_family: font.family.clone(),
font_resolver: pinned_resolver(font.bundled_id),
..Default::default()
};
// SECURITY: usvg's default string resolver reads image hrefs off disk
// (`std::fs::read`). Replace it with a no-op so a crafted SVG can never read
// local files or reach the network. In-memory data-URLs stay supported.
opt.image_href_resolver.resolve_string = Box::new(|_href, _opt| None);
let tree =
usvg::Tree::from_str(svg, &opt).map_err(|e| MermaidError::Rasterize(e.to_string()))?;
let size = tree.size();
let (base_w, base_h) = (size.width(), size.height());
if base_w <= 0.0 || base_h <= 0.0 {
return Err(MermaidError::Rasterize("diagram has zero size".to_string()));
}
let scale = effective_scale(base_w, base_h, params);
let (width_px, height_px) = clamp_dimensions(base_w * scale, base_h * scale);
let mut pixmap = tiny_skia::Pixmap::new(width_px, height_px).ok_or_else(|| {
MermaidError::Rasterize(format!("invalid pixmap size {width_px}x{height_px}"))
})?;
if let Some(bg) = params.background {
pixmap.fill(rgba_to_color(bg));
}
// Scale each axis to fill the chosen pixmap exactly. After the integer
// clamps the two axis scales can differ by up to ~1/base_dim, a sub-pixel
// aspect skew for typical sizes; we prefer an exact fill (no transparent
// margins) over perfect aspect preservation.
let transform =
tiny_skia::Transform::from_scale(width_px as f32 / base_w, height_px as f32 / base_h);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let png = pixmap
.encode_png()
.map_err(|e| MermaidError::Rasterize(e.to_string()))?;
tracing::debug!(target: "mermaid", width_px, height_px, png_bytes = png.len(), "rasterized svg");
Ok(RenderedDiagram {
png,
width_px,
height_px,
})
}
/// Compute the scale factor applied to the SVG's intrinsic size, honoring the
/// target width (or fallback scale), optional minimum width, then clamping by
/// max height and max output area.
fn effective_scale(base_w: f32, base_h: f32, params: &RenderParams) -> f32 {
let mut scale = if params.target_width_px > 0 {
params.target_width_px as f32 / base_w
} else {
params.scale
};
if !scale.is_finite() || scale <= 0.0 {
scale = 1.0;
}
// Upscale small diagrams so OS viewers get a usable pixel budget (applied
// before height/area clamps so a min-width request can still shrink to fit).
if params.min_width_px > 0 {
let min_scale = params.min_width_px as f32 / base_w;
if min_scale.is_finite() && min_scale > scale {
scale = min_scale;
}
}
if params.max_height_px > 0 {
let max_h = params.max_height_px as f32;
if base_h * scale > max_h {
scale = max_h / base_h;
}
}
let max_area = MAX_OUTPUT_MEGAPIXELS * 1_000_000.0;
let area = (base_w * scale) * (base_h * scale);
if area > max_area {
scale *= (max_area / area).sqrt();
}
scale.max(f32::MIN_POSITIVE)
}
/// Convert floored float dimensions into the final integer pixmap size,
/// enforcing the hard caps.
///
/// `effective_scale` caps the *float* area, but flooring a sub-1px axis up to 1
/// (via `.max(1)`) can inflate the integer product past the cap for an
/// extreme-aspect diagram. So after floor+`max(1)` we additionally (a) cap each
/// axis at [`MAX_OUTPUT_DIMENSION`] and (b) shrink the larger axis until
/// `width * height <= MAX_OUTPUT_MEGAPIXELS`, guaranteeing the bound the doc
/// promises.
fn clamp_dimensions(width_f: f32, height_f: f32) -> (u32, u32) {
let mut width_px = (width_f.floor() as u32).clamp(1, MAX_OUTPUT_DIMENSION);
let mut height_px = (height_f.floor() as u32).clamp(1, MAX_OUTPUT_DIMENSION);
let max_area = (MAX_OUTPUT_MEGAPIXELS * 1_000_000.0) as u64;
if width_px as u64 * height_px as u64 > max_area {
if width_px >= height_px {
width_px = ((max_area / height_px as u64) as u32).max(1);
} else {
height_px = ((max_area / width_px as u64) as u32).max(1);
}
}
(width_px, height_px)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MermaidTheme;
// A 100x50 SVG with a small 10x10 blue square in the top-left; the rest of
// the canvas is empty (so the background shows through there).
const SVG_100X50: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><rect x="0" y="0" width="10" height="10" fill="#0000ff"/></svg>"##;
fn params(target_width_px: u32, max_height_px: u32) -> RenderParams {
RenderParams {
theme: MermaidTheme::Light,
target_width_px,
max_height_px,
scale: 1.0,
min_width_px: 0,
background: None,
}
}
#[test]
fn min_width_raises_scale_before_clamps() {
// 100-wide SVG, scale 1.0 would be 100px; min_width 400 => 4x => 400x200.
let mut p = params(0, 10_000);
p.min_width_px = 400;
let out = rasterize(SVG_100X50, &p).expect("rasterize");
assert_eq!(out.width_px, 400);
assert_eq!(out.height_px, 200);
}
#[test]
fn for_os_viewer_uses_2x_or_min_width() {
// Small SVG: min_width 2560 wins over 2× (200).
let p = RenderParams::for_os_viewer(MermaidTheme::Light, 2560, 8192);
let out = rasterize(SVG_100X50, &p).expect("rasterize");
assert_eq!(out.width_px, 2560);
assert_eq!(out.height_px, 1280);
// Wide SVG: 2× intrinsic (target_width 0, scale 2) when already ≥ min.
let wide = r##"<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="500" viewBox="0 0 2000 500"><rect width="2000" height="500" fill="#00ff00"/></svg>"##;
let out2 = rasterize(wide, &p).expect("rasterize");
assert_eq!(out2.width_px, 4000);
assert_eq!(out2.height_px, 1000);
}
#[test]
fn rasterize_decodes_to_expected_scaled_dimensions() {
// target width 200 against a 100-wide SVG => 2x => 200x100.
let out = rasterize(SVG_100X50, &params(200, 10_000)).expect("rasterize");
assert_eq!(out.width_px, 200);
assert_eq!(out.height_px, 100);
let img = image::load_from_memory(&out.png).expect("decode png");
assert_eq!(img.width(), 200);
assert_eq!(img.height(), 100);
}
#[test]
fn rasterize_fallback_scale_when_no_target_width() {
// target_width_px == 0 falls back to `scale`.
let mut p = params(0, 10_000);
p.scale = 3.0;
let out = rasterize(SVG_100X50, &p).expect("rasterize");
assert_eq!(out.width_px, 300);
assert_eq!(out.height_px, 150);
}
#[test]
fn rasterize_is_deterministic() {
let a = rasterize(SVG_100X50, &params(200, 10_000)).expect("a");
let b = rasterize(SVG_100X50, &params(200, 10_000)).expect("b");
assert_eq!(
a.png, b.png,
"same svg+params must yield identical png bytes"
);
}
#[test]
fn opaque_background_fills_empty_region() {
let mut p = params(0, 10_000); // 1x => 100x50 so pixel coords are exact
p.background = Some(Rgba::new(255, 0, 0, 255));
let out = rasterize(SVG_100X50, &p).expect("rasterize");
let img = image::load_from_memory(&out.png)
.expect("decode")
.to_rgba8();
// Bottom-right is outside the blue square => background red, fully opaque.
let px = img.get_pixel(95, 45);
assert_eq!(px.0, [255, 0, 0, 255]);
}
#[test]
fn no_background_leaves_empty_region_transparent() {
let out = rasterize(SVG_100X50, &params(0, 10_000)).expect("rasterize");
let img = image::load_from_memory(&out.png)
.expect("decode")
.to_rgba8();
let px = img.get_pixel(95, 45);
assert_eq!(px.0[3], 0, "empty region must be transparent without a bg");
}
#[test]
fn invalid_svg_is_rasterize_error() {
let err = rasterize("this is definitely not svg", &params(200, 10_000))
.expect_err("garbage must not parse");
assert!(matches!(err, MermaidError::Rasterize(_)));
}
#[test]
fn max_height_clamps_tall_diagram() {
let tall = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="1000" viewBox="0 0 100 1000"><rect width="100" height="1000" fill="#00ff00"/></svg>"##;
// scale fallback 2.0 would give height 2000; clamp to 200.
let mut p = params(0, 200);
p.scale = 2.0;
let out = rasterize(tall, &p).expect("rasterize");
assert!(out.height_px <= 200, "height {} exceeds cap", out.height_px);
// Aspect ratio preserved: 100/1000 => width ~ height/10.
assert!(out.width_px <= 40);
}
#[test]
fn megapixel_cap_bounds_huge_request() {
let big = r##"<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="#123456"/></svg>"##;
// Absurd target width; without the area cap this would be ~1e10 px.
let out = rasterize(big, &params(200_000, u32::MAX)).expect("rasterize");
let area = out.width_px as u64 * out.height_px as u64;
assert!(
area <= (MAX_OUTPUT_MEGAPIXELS as u64) * 1_000_000,
"area {area} exceeds megapixel cap"
);
// Still a valid, decodable PNG.
assert!(image::load_from_memory(&out.png).is_ok());
}
#[test]
fn extreme_aspect_svg_respects_area_and_axis_caps() {
// A pathological 3.2e9 x 1 SVG: the float area cap leaves one axis huge,
// and floor+max(1) on the sub-1px axis would otherwise inflate the
// integer area far past the cap (the bypass). Verify both the
// area cap and the per-axis cap hold.
let wide = r##"<svg xmlns="http://www.w3.org/2000/svg" width="3200000000" height="1" viewBox="0 0 3200000000 1"><rect width="3200000000" height="1" fill="#abcdef"/></svg>"##;
let out = rasterize(wide, &params(0, u32::MAX)).expect("rasterize");
let area = out.width_px as u64 * out.height_px as u64;
assert!(
area <= (MAX_OUTPUT_MEGAPIXELS as u64) * 1_000_000,
"area {area} exceeds megapixel cap"
);
assert!(
out.width_px <= MAX_OUTPUT_DIMENSION && out.height_px <= MAX_OUTPUT_DIMENSION,
"axis exceeds per-dimension cap: {}x{}",
out.width_px,
out.height_px
);
assert!(out.width_px >= 1 && out.height_px >= 1);
assert!(image::load_from_memory(&out.png).is_ok());
}
#[test]
fn clamp_dimensions_shrinks_larger_axis_to_area_cap() {
// Direct coverage of the area-shrink branch (unreachable via `rasterize`
// because `effective_scale` pre-caps the float area), exercising both
// arms of the inner `if`.
let max_area = (MAX_OUTPUT_MEGAPIXELS as u64) * 1_000_000;
// Both axes hit the per-axis cap; equal => the `width >= height` arm
// shrinks width.
let (w, h) = clamp_dimensions(20_000.0, 20_000.0);
assert_eq!((w, h), (1953, MAX_OUTPUT_DIMENSION));
assert!(w as u64 * h as u64 <= max_area);
// Width below the per-axis cap, height at it => the `else` arm shrinks
// height.
let (w2, h2) = clamp_dimensions(10_000.0, 20_000.0);
assert_eq!((w2, h2), (10_000, 3200));
assert!(w2 as u64 * h2 as u64 <= max_area);
}
#[test]
fn narrow_tall_svg_clamps_width_to_one() {
// width 1, height 1000, clamped to height 5 => width 1*0.005 floors to 0
// and is bumped to 1 (the `.max(1)` boundary).
let narrow = r##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1000" viewBox="0 0 1 1000"><rect width="1" height="1000" fill="#00ff00"/></svg>"##;
let out = rasterize(narrow, &params(0, 5)).expect("rasterize");
assert_eq!(out.width_px, 1, "narrow width must clamp to 1");
assert!(out.height_px <= 5);
assert!(image::load_from_memory(&out.png).is_ok());
}
#[test]
fn zero_size_svg_is_rasterize_error() {
// A zero-dimension SVG either fails to parse or hits the zero-size
// guard; both surface as Rasterize, never a panic or a 0x0 pixmap.
let zero = r##"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>"##;
let r = rasterize(zero, &params(200, 10_000));
assert!(matches!(r, Err(MermaidError::Rasterize(_))), "got {r:?}");
}
#[test]
fn non_finite_or_non_positive_scale_clamps_to_valid_png() {
for bad_scale in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
let mut p = params(0, 10_000); // target_width_px == 0 => scale path
p.scale = bad_scale;
let out = rasterize(SVG_100X50, &p)
.unwrap_or_else(|e| panic!("scale {bad_scale} should clamp to 1.0, got {e:?}"));
assert_eq!(
(out.width_px, out.height_px),
(100, 50),
"scale {bad_scale} should clamp to 1.0"
);
assert!(image::load_from_memory(&out.png).is_ok());
}
}
#[test]
fn malformed_svgs_never_panic_and_only_rasterize_error() {
// Engine-agnostic untrusted-input contract (runs in the default build):
// rasterize must always return (no panic) and only ever produce
// Rasterize errors for malformed/partial SVG.
let inputs = [
"",
"<svg",
"<svg></svg>",
r#"<svg xmlns="http://www.w3.org/2000/svg" width="abc" height="xyz"></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="1e999" height="1"></svg>"#,
"<><><>",
"\u{0}\u{1}\u{2}\u{3}",
];
for input in inputs {
let r = rasterize(input, &params(200, 10_000));
assert!(
matches!(r, Ok(_) | Err(MermaidError::Rasterize(_))),
"input {input:?} produced unexpected {r:?}"
);
}
// A clearly-wrong root element must error, not silently succeed.
assert!(matches!(
rasterize("<not-svg/>", &params(200, 10_000)),
Err(MermaidError::Rasterize(_))
));
}
#[test]
fn text_with_engine_font_family_actually_renders_glyphs() {
// Regression: the engine themes set font-family lists like
// "Inter, …, sans-serif" — none of which name the bundled Roboto face.
// usvg resolves the generic `sans-serif` via fontdb's generic-family
// map (default "Arial"), which isn't loaded, so unless the generics
// point at the bundled face the glyphs are silently dropped and node
// labels render blank. Black text on white: assert dark pixels exist.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60" viewBox="0 0 200 60"><text x="10" y="38" font-family="Inter, ui-sans-serif, system-ui, -apple-system, &quot;Segoe UI&quot;, sans-serif" font-size="28" fill="#000000">Hello</text></svg>"##;
let mut p = params(0, 10_000); // scale 1.0 => 200x60
p.background = Some(Rgba::new(255, 255, 255, 255));
let out = rasterize(svg, &p).expect("rasterize");
let img = image::load_from_memory(&out.png)
.expect("decode")
.to_rgba8();
let dark = img
.pixels()
.filter(|px| px.0[0] < 64 && px.0[1] < 64 && px.0[2] < 64)
.count();
assert!(
dark > 50,
"node-label glyphs were dropped (found {dark} dark px): the bundled \
font is not wired to usvg's generic families",
);
}
#[test]
fn bundled_font_family_is_resolved() {
// Sanity: the bundled face exposes a usable family name (not the
// generic fallback), which the SVG font-family resolves against.
assert_ne!(bundled_font().family, "sans-serif");
assert!(!bundled_font().family.is_empty());
}
#[test]
fn ascii_svg_uses_bundled_only_database() {
let set = font_set_for(SVG_100X50);
assert!(std::ptr::eq(set, bundled_font()));
assert_eq!(set.db.faces().count(), 1);
assert_eq!(set.db.faces().next().map(|f| f.id), Some(set.bundled_id));
}
#[test]
fn non_ascii_svg_uses_system_fallback_database() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><text x="5" y="30" font-size="20">中</text></svg>"##;
let set = font_set_for(svg);
assert!(std::ptr::eq(set, font_with_system_fallback()));
assert_eq!(set.db.faces().next().map(|f| f.id), Some(set.bundled_id));
assert_eq!(set.family, bundled_font().family);
}
#[test]
fn cjk_text_falls_back_to_system_fonts_when_available() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="60" viewBox="0 0 300 60"><text x="10" y="40" font-family="sans-serif" font-size="28" fill="#000000">提交代码</text></svg>"##;
let mut p = params(0, 10_000);
p.background = Some(Rgba::new(255, 255, 255, 255));
let with_fallback = rasterize(svg, &p).expect("fallback render");
let tofu = rasterize_with_font(svg, &p, bundled_font()).expect("bundled-only render");
assert_eq!(
(with_fallback.width_px, with_fallback.height_px),
(tofu.width_px, tofu.height_px),
"font fallback must not change output dimensions"
);
if with_fallback.png == tofu.png {
eprintln!("skipping: no system font covers CJK on this host");
return;
}
let img = image::load_from_memory(&with_fallback.png)
.expect("decode")
.to_rgba8();
let dark = img
.pixels()
.filter(|px| px.0[0] < 64 && px.0[1] < 64 && px.0[2] < 64)
.count();
assert!(
dark > 100,
"expected real CJK glyph coverage, found {dark} dark px"
);
}
#[test]
fn named_system_family_cannot_hijack_shaping() {
let named = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60" viewBox="0 0 200 60"><text x="10" y="38" font-family="Arial, Helvetica, Verdana" font-size="28" fill="#000000">Hello é</text></svg>"##;
let mut p = params(0, 10_000);
p.background = Some(Rgba::new(255, 255, 255, 255));
let via_fallback_db =
rasterize_with_font(named, &p, font_with_system_fallback()).expect("system-db render");
let via_bundled_db =
rasterize_with_font(named, &p, bundled_font()).expect("bundled render");
assert_eq!(
via_fallback_db.png, via_bundled_db.png,
"Latin text must shape identically with and without system fonts loaded"
);
}
}
@@ -0,0 +1,310 @@
//! Shared subprocess plumbing: spawn a child, optionally feed it stdin, wait up
//! to a wall-clock budget, and reap the whole process group on a breach.
//!
//! Used by both the optional [`crate::MmdcEngine`] (which shells out to
//! `mmdc`/headless-Chromium) and the pager's out-of-process render child (a
//! short-lived re-exec of the pager that renders one diagram in isolation). The
//! timeout is a *real* process kill, not a soft signal: a panic under
//! `panic = "abort"` or a runaway render in the child is contained because the
//! parent kills and reaps it.
//!
//! The caller builds the [`Command`] (stdio, env, and the sanctioned
//! TTY/console detach via `kigi_tty_utils::detach_std_command`); this module only
//! owns the spawn → feed-stdin → wait → reap lifecycle so neither call site
//! re-implements process-group teardown.
use std::process::{Child, Command};
use std::time::Duration;
use wait_timeout::ChildExt;
/// Why a child subprocess run did not complete successfully.
#[derive(thiserror::Error, Debug)]
pub enum SubprocessError {
/// The child could not be spawned (binary missing, fork failure, …).
#[error("could not spawn child process: {0}")]
Spawn(std::io::Error),
/// The child exceeded its wall-clock budget and was killed and reaped.
#[error("child process timed out")]
Timeout,
/// The child ran to completion but exited non-zero.
#[error("child process exited with {0}")]
NonZeroExit(std::process::ExitStatus),
/// Waiting on the child itself failed; the child was reaped defensively.
#[error("waiting on child process failed: {0}")]
Wait(std::io::Error),
}
/// Spawn `cmd`, optionally write `stdin_payload` to its stdin, wait up to
/// `timeout`, and reap the process group on a breach.
///
/// The caller must have configured `cmd` (stdio, env, detach). To pass
/// `stdin_payload`, the caller must set `cmd.stdin(Stdio::piped())`; the payload
/// is written from a scoped thread so a full pipe buffer can never deadlock the
/// wait. When `stdin_payload` is `None` (or stdin is not piped), no writer runs.
///
/// Returns `Ok(())` only on a zero-exit run; otherwise the matching
/// [`SubprocessError`]. On timeout or a failed wait the child is killed and
/// reaped: on Unix the whole process group is SIGKILLed, so grandchildren (e.g.
/// an [`crate::MmdcEngine`]'s headless Chromium) are reaped too; on Windows only
/// the direct child is killed — sufficient for the pager's render child (no
/// grandchildren), but a Windows `MmdcEngine` could leak Chromium grandchildren
/// (a Job Object is the follow-up there).
pub fn run_with_timeout(
mut cmd: Command,
stdin_payload: Option<&[u8]>,
timeout: Duration,
) -> Result<(), SubprocessError> {
let mut child = spawn_with_etxtbsy_retry(&mut cmd).map_err(SubprocessError::Spawn)?;
// Feed stdin from a scoped thread so a child that stops reading can't wedge
// a `write_all` of a large (up to the source-size cap) payload and deadlock
// the wait below. On timeout we kill the child, which EOF/EPIPEs the writer.
let stdin = child.stdin.take();
// A payload with no piped stdin would be silently dropped (the caller forgot
// `cmd.stdin(Stdio::piped())`). Both in-tree callers pipe correctly; flag the
// foot-gun loudly in debug and at least log it in release.
if stdin_payload.is_some() && stdin.is_none() {
tracing::warn!(
target: "mermaid",
"run_with_timeout: stdin payload supplied but stdin is not piped; payload dropped"
);
debug_assert!(
false,
"run_with_timeout: stdin_payload supplied but cmd.stdin is not piped (payload dropped)"
);
}
std::thread::scope(|scope| {
if let (Some(mut sink), Some(payload)) = (stdin, stdin_payload) {
scope.spawn(move || {
use std::io::Write as _;
// Errors are expected if the child exits/dies first; ignore them.
let _ = sink.write_all(payload);
// Dropping `sink` closes the pipe so the child observes EOF.
});
}
wait_and_reap(&mut child, timeout)
})
}
/// Spawn `cmd`, retrying briefly on `ETXTBSY` ("Text file busy").
///
/// On Linux, exec'ing a binary that another thread/process still holds open for
/// writing fails with `ExecutableFileBusy`. A concurrent `Command::spawn` on
/// another thread forks and inherits any write fd open at that instant; the fd
/// is close-on-exec but only closes at the child's own `execve`, leaving a
/// fork→execve window during which our `execve` of a freshly-written binary can
/// race. It is transient and clears within milliseconds, so retry a few times
/// with a short backoff. (No-op on the steady-state path; only the failing
/// transient case changes behaviour.)
fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
const MAX_ATTEMPTS: u32 = 5;
let mut attempt = 0;
loop {
match cmd.spawn() {
Ok(child) => return Ok(child),
Err(e)
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
&& attempt + 1 < MAX_ATTEMPTS =>
{
attempt += 1;
std::thread::sleep(Duration::from_millis(20 * attempt as u64));
}
Err(e) => return Err(e),
}
}
}
/// Wait for `child` up to `timeout`, tearing down its detached process group on
/// every exit path (success, non-zero exit, timeout, wait failure) so a child
/// that spawned grandchildren can't orphan them.
fn wait_and_reap(child: &mut Child, timeout: Duration) -> Result<(), SubprocessError> {
match child.wait_timeout(timeout) {
// `wait_timeout` already reaped the direct child on these two branches,
// but it was its own detached group leader: SIGKILL the group's pgid so
// any grandchildren (e.g. an opt-in MmdcEngine's headless Chromium) are
// torn down regardless of exit code. The pgid stays valid while a
// grandchild is alive (the case that matters); for the grandchild-less
// render child the leader is already gone, so killpg is a harmless no-op
// (ESRCH). The full `reap()` is unneeded — the direct child is already
// reaped, so `child.kill()`/`wait()` would be redundant.
Ok(Some(status)) if status.success() => {
reap_process_group(child);
Ok(())
}
Ok(Some(status)) => {
reap_process_group(child);
Err(SubprocessError::NonZeroExit(status))
}
Ok(None) => {
reap(child);
Err(SubprocessError::Timeout)
}
// waitpid failed; the child may still be running, so reap it too — the
// same teardown as the timeout branch (don't leak the child tree).
Err(e) => {
reap(child);
Err(SubprocessError::Wait(e))
}
}
}
/// Best-effort teardown of a spawned child: SIGKILL its process group (to reach
/// any grandchildren, e.g. headless Chromium), then kill and reap the child.
fn reap(child: &mut Child) {
reap_process_group(child);
let _ = child.kill();
let _ = child.wait();
}
/// SIGKILL the child's process group so grandchildren are reaped, not just the
/// direct child.
///
/// `kigi_tty_utils::detach_std_command` runs `setsid` (EPERM fallback
/// `setpgid(0,0)`), so the child is its own group leader and its pgid equals its
/// pid. We send the signal directly because `kigi_tty_utils::ProcessGroup` only
/// wraps tokio children.
#[cfg(unix)]
fn reap_process_group(child: &Child) {
let pid = child.id() as libc::pid_t;
// SAFETY: killpg with a valid pid + standard signal has no memory effects.
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
}
#[cfg(not(unix))]
fn reap_process_group(_child: &Child) {
// Group teardown via Job Objects is tokio-only here; the caller's
// `child.kill()` still terminates the direct child process.
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Stdio;
use std::time::Instant;
fn detached(mut cmd: Command) -> Command {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
kigi_tty_utils::detach_std_command(&mut cmd);
cmd
}
#[cfg(unix)]
#[test]
fn zero_exit_is_ok() {
let cmd = detached(Command::new("true"));
assert!(run_with_timeout(cmd, None, Duration::from_secs(5)).is_ok());
}
#[cfg(unix)]
#[test]
fn nonzero_exit_is_reported() {
let cmd = detached(Command::new("false"));
let r = run_with_timeout(cmd, None, Duration::from_secs(5));
assert!(
matches!(r, Err(SubprocessError::NonZeroExit(_))),
"got {r:?}"
);
}
#[cfg(unix)]
#[test]
fn slow_command_times_out_quickly() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
let cmd = detached(cmd);
let start = Instant::now();
let r = run_with_timeout(cmd, None, Duration::from_millis(150));
assert!(matches!(r, Err(SubprocessError::Timeout)));
assert!(
start.elapsed() < Duration::from_secs(2),
"should return at the deadline, not wait the full 5s",
);
}
/// A large stdin payload (bigger than any OS pipe buffer) must be delivered
/// through `run_with_timeout`'s own scoped writer without deadlocking the
/// wait — the whole reason the writer is a scoped thread. We point `cat` at a
/// file (its stdout) so we can prove every byte was consumed, and assert the
/// call returns `Ok(())` promptly rather than hitting the timeout.
#[cfg(unix)]
#[test]
fn large_stdin_payload_is_delivered_without_deadlock() {
let payload = vec![b'x'; 256 * 1024];
let dir = tempfile::tempdir().expect("tempdir");
let sink = dir.path().join("drained");
let sink_file = std::fs::File::create(&sink).expect("create sink");
let mut cmd = Command::new("cat");
cmd.stdin(Stdio::piped())
.stdout(Stdio::from(sink_file))
.stderr(Stdio::null());
kigi_tty_utils::detach_std_command(&mut cmd);
let start = Instant::now();
let r = run_with_timeout(cmd, Some(&payload), Duration::from_secs(10));
assert!(
r.is_ok(),
"draining a large stdin payload must succeed: {r:?}"
);
assert!(
start.elapsed() < Duration::from_secs(5),
"must return after the drain, not after the full timeout",
);
// `cat` copies all of stdin to the file → proves the whole payload was
// both delivered and consumed via the real scoped writer.
let drained = std::fs::metadata(&sink).expect("sink metadata").len();
assert_eq!(
drained,
payload.len() as u64,
"all stdin bytes round-tripped through cat"
);
}
/// `reap` actually terminates the spawned process group.
#[cfg(unix)]
#[test]
fn reap_terminates_the_process() {
let mut cmd = Command::new("sleep");
cmd.arg("30");
let mut cmd = detached(cmd);
let mut child = cmd.spawn().expect("spawn sleep");
let pid = child.id() as libc::pid_t;
reap(&mut child);
// After SIGKILL + wait, the pid no longer names a live process.
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ESRCH),
"process {pid} should be gone after reap",
);
}
#[test]
fn missing_binary_is_spawn_error() {
let cmd = Command::new("definitely-not-a-real-binary-9f8a7b6c5d4e");
let r = run_with_timeout(cmd, None, Duration::from_secs(5));
assert!(matches!(r, Err(SubprocessError::Spawn(_))), "got {r:?}");
}
/// The dropped-stdin-payload guard fires when a caller passes a payload but
/// forgets `cmd.stdin(Stdio::piped())`: the `debug_assert!` turns that silent
/// foot-gun into a hard failure. Gated on `debug_assertions` because that is
/// exactly when the assert is active (release keeps only the `warn`). `true`
/// exits at once; `detached` sets stdin to null (not piped), so the payload
/// would be dropped and the guard must catch it.
#[cfg(all(unix, debug_assertions))]
#[test]
#[should_panic(expected = "stdin_payload supplied but cmd.stdin is not piped")]
fn stdin_payload_without_piped_stdin_is_flagged() {
let cmd = detached(Command::new("true"));
let _ = run_with_timeout(cmd, Some(b"payload"), Duration::from_secs(5));
}
}
@@ -0,0 +1,178 @@
//! Integration tests for the public render path through the default engine.
//!
//! These use only the public API (`default_engine` + `render_checked`) so they
//! exercise the real, always-compiled dagre-based engine end to end.
use kigi_mermaid::{
MermaidError, MermaidTheme, RenderLimits, RenderParams, default_engine, render_checked,
};
#[test]
fn default_engine_renders_a_flowchart() {
let diagram = render_checked(
default_engine().as_ref(),
"flowchart LR\n A[Start] --> B[Finish]",
&RenderParams::default(),
&RenderLimits::default(),
)
.expect("the default engine must render a flowchart");
assert!(diagram.width_px > 0 && diagram.height_px > 0);
let img = image::load_from_memory(&diagram.png).expect("output must be a valid PNG");
assert_eq!(img.width(), diagram.width_px);
assert_eq!(img.height(), diagram.height_px);
}
/// Untrusted-input contract through the real engine: a panic would surface as
/// `MermaidError::Panic`, which we assert against. Unparseable input may return
/// other errors (which degrade to the code-block fallback), never a panic.
#[test]
fn garbage_input_is_isolated_not_panicked() {
let limits = RenderLimits::default();
let params = RenderParams::default();
for garbage in ["", "@@@@", "%% comment only", "????", "\u{0}\u{1}\u{2}"] {
let out = render_checked(default_engine().as_ref(), garbage, &params, &limits);
assert!(
!matches!(out, Err(MermaidError::Panic(_))),
"engine panicked on {garbage:?}: {out:?}"
);
}
}
#[test]
fn sequence_diagram_with_activations_renders_to_png() {
const SOURCE: &str = "sequenceDiagram\n\
participant Dev as Developer\n\
participant CI as CI Pipeline\n\
participant K8s as Kubernetes\n\
participant PD as PagerDuty\n\
participant Cat as Office Cat\n\
Dev->>CI: git push --force\n\
activate CI\n\
CI->>CI: run 4,000 tests\n\
CI-->>Dev: \u{2705} all green (suspicious)\n\
deactivate CI\n\
Dev->>K8s: deploy to prod\n\
activate K8s\n\
K8s-->>Dev: 200 OK\n\
deactivate K8s\n\
Note over Dev: leaves for lunch\n\
K8s->>PD: OOMKilled x47\n\
PD->>Dev: CALL CALL CALL\n\
Dev->>K8s: kubectl rollout undo\n\
K8s-->>Dev: phew\n\
Cat->>Dev: sits on keyboard\n\
Dev->>K8s: jjjjjjjjjjjjjjjj\n";
let diagram = render_checked(
default_engine().as_ref(),
SOURCE,
&RenderParams::default(),
&RenderLimits::default(),
)
.expect("a sequence diagram with activations must render");
let img = image::load_from_memory(&diagram.png).expect("output must be a valid PNG");
assert_eq!(img.width(), diagram.width_px);
assert_eq!(img.height(), diagram.height_px);
}
/// Regression: a class diagram using quoted cardinalities, stereotypes,
/// generics, and the full relation set must render instead of erroring
/// (previously failed with "Unrecognized classDiagram line" on
/// `Owner "1" o-- "0..*" Animal`).
#[test]
fn class_diagram_with_cardinalities_renders() {
let src = "classDiagram\n direction TB\n class Animal {\n <<abstract>>\n #String name\n +makeSound()* String\n }\n class Owner {\n +List~Animal~ pets\n +adopt(Animal pet) void\n }\n Animal <|-- Dog : extends\n Feedable <|.. Animal : implements\n Owner \"1\" o-- \"0..*\" Animal : owns\n Dog \"1\" --> \"0..1\" Ball : plays with\n Veterinarian ..> Animal : examines\n HealthReport --* Animal : belongs to";
let engine = default_engine();
let diagram = render_checked(
engine.as_ref(),
src,
&RenderParams {
theme: MermaidTheme::Dark,
..Default::default()
},
&RenderLimits::default(),
)
.expect("class diagram with quoted cardinalities must render");
assert!(!diagram.png.is_empty());
}
/// Same source + params must produce identical PNG bytes (the engine's text
/// metrics are font-free, so rendering is deterministic).
#[test]
fn rendering_is_deterministic() {
let engine = default_engine();
let params = RenderParams::default();
let a = render_checked(
engine.as_ref(),
"flowchart LR\nA-->B-->C",
&params,
&RenderLimits::default(),
)
.expect("render a");
let b = render_checked(
engine.as_ref(),
"flowchart LR\nA-->B-->C",
&params,
&RenderLimits::default(),
)
.expect("render b");
assert_eq!(a.png, b.png, "same input must yield identical PNG bytes");
}
/// End-to-end guard for the word-wrap fix: the real user diagram (a flowchart of
/// long Python identifiers) must reach the rendered SVG with its identifiers
/// intact, never hard-sliced mid-identifier. Renders through the same
/// `mermaid_to_svg::render_mermaid_to_svg` path the pager uses.
#[test]
fn long_identifier_node_labels_survive_intact_in_svg() {
let src = "flowchart TB
subgraph main [resi_local.py / resi.py]
nav[st.navigation]
mark[mark_filter_restore_context]
global[render_global_sidebar]
sidebar[render_page_sidebar_filters]
page[page.run]
end
nav --> mark --> global --> sidebar --> page";
let svg = mermaid_to_svg::render_mermaid_to_svg(src, None)
.expect("the real user flowchart must render to SVG");
// Each long identifier appears as a COMPLETE single tspan; a slice (at any
// offset) could never produce the whole identifier as one tspan's content.
assert!(
svg.contains(">mark_filter_restore_context</tspan>"),
"{svg}"
);
assert!(
svg.contains(">render_page_sidebar_filters</tspan>"),
"{svg}"
);
}
/// A categorical-x-axis xychart with two `line` series must render to a decodable
/// PNG through the full `[Open Image]` path (source -> SVG -> raster), on both
/// themes. The categorical x-axis (no `-->`) previously failed to open.
#[test]
fn categorical_xychart_with_two_series_renders_to_png() {
const SOURCE: &str = "xychart-beta\n \
title \"Weekly active users by region\"\n \
x-axis [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n \
y-axis \"% of users\" 0 --> 40\n \
line [20.3, 22.6, 24.2, 24.3, 26.2, 27.2, 32.4, 31.9, 31.4, 31.1, 33.6, 34.3]\n \
line [3.2, 6.3, 10.0, 9.4, 11.1, 10.7, 15.3, 13.4, 13.5, 12.5, 15.4, 15.8]";
let engine = default_engine();
for theme in [MermaidTheme::Light, MermaidTheme::Dark] {
let diagram = render_checked(
engine.as_ref(),
SOURCE,
&RenderParams {
theme,
..Default::default()
},
&RenderLimits::default(),
)
.unwrap_or_else(|e| panic!("categorical xychart must render ({theme:?}): {e}"));
let img = image::load_from_memory(&diagram.png).expect("output must be a valid PNG");
assert_eq!(img.width(), diagram.width_px);
assert_eq!(img.height(), diagram.height_px);
assert!(diagram.width_px > 0 && diagram.height_px > 0);
}
}