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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms, &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, 0–255.
|
||||
pub r: u8,
|
||||
/// Green channel, 0–255.
|
||||
pub g: u8,
|
||||
/// Blue channel, 0–255.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, &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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms(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, ¶ms(200, 10_000)).expect("a");
|
||||
let b = rasterize(SVG_100X50, ¶ms(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, ¶ms(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", ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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/>", ¶ms(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, "Segoe UI", 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user