Skip to main content

fluidattacks_blends/
svg.rs

1//! DOT source through Graphviz into an SVG file. Development-only: it shells
2//! out to `dot`, which the production scanner does not ship.
3
4use std::ffi::OsString;
5use std::fmt;
6use std::fs;
7use std::io::Write as _;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::thread;
11
12use blends_domain::graph_set::GraphSet;
13
14use crate::dot::DotGraph;
15
16const DOT_BINARY: &str = "dot";
17
18#[derive(Clone, PartialEq, Eq, Debug)]
19pub struct SvgRenderError(String);
20
21impl fmt::Display for SvgRenderError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str(&self.0)
24    }
25}
26
27impl std::error::Error for SvgRenderError {}
28
29fn missing_binary() -> SvgRenderError {
30    SvgRenderError(format!(
31        "The '{DOT_BINARY}' executable was not found. Graph rendering is a development-only \
32         feature: install Graphviz (brew install graphviz, apt install graphviz) or use the \
33         blends development environment"
34    ))
35}
36
37/// Feeds `source` to `program` on stdin and returns the SVG it writes out.
38/// Fixed arguments and no shell, so stdin is the only caller-supplied value.
39fn run(program: &str, source: &str) -> Result<String, SvgRenderError> {
40    let mut child = Command::new(program)
41        .arg("-Tsvg")
42        .stdin(Stdio::piped())
43        .stdout(Stdio::piped())
44        .stderr(Stdio::piped())
45        .spawn()
46        .map_err(|error| match error.kind() {
47            std::io::ErrorKind::NotFound => missing_binary(),
48            _ => SvgRenderError(format!("could not run {program}: {error}")),
49        })?;
50
51    let mut stdin = child
52        .stdin
53        .take()
54        .ok_or_else(|| SvgRenderError(format!("{program} exposed no stdin")))?;
55
56    // Write stdin on a thread so the output pipes are drained meanwhile. Writing
57    // inline deadlocks against a child that fills stdout or stderr first.
58    // Scoped so the thread borrows `source` instead of copying it.
59    let (output, written) = thread::scope(|scope| {
60        // `move` hands over `stdin`, closing the pipe when the thread ends.
61        let writer = scope.spawn(move || stdin.write_all(source.as_bytes()));
62        // Drain before joining, or the deadlock is back.
63        let output = child.wait_with_output();
64
65        (output, writer.join())
66    });
67
68    let output = output
69        .map_err(|error| SvgRenderError(format!("could not read from {program}: {error}")))?;
70
71    if !output.status.success() {
72        let code = output
73            .status
74            .code()
75            .map_or_else(|| "signal".to_owned(), |code| code.to_string());
76        let stderr = String::from_utf8_lossy(&output.stderr);
77
78        // Before the writer error: a child that left early broke the pipe, so
79        // the exit status is the real diagnosis.
80        return Err(SvgRenderError(format!(
81            "{DOT_BINARY} exited with code {code}: {}",
82            stderr.trim()
83        )));
84    }
85
86    written
87        .map_err(|_| SvgRenderError(format!("the {program} writer thread panicked")))?
88        .map_err(|error| SvgRenderError(format!("could not write to {program}: {error}")))?;
89
90    String::from_utf8(output.stdout)
91        .map_err(|error| SvgRenderError(format!("{program} emitted invalid utf-8: {error}")))
92}
93
94fn write(svg: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
95    if let Some(parent) = path.parent() {
96        fs::create_dir_all(parent).map_err(|error| {
97            SvgRenderError(format!("could not create {}: {error}", parent.display()))
98        })?;
99    }
100
101    fs::write(path, svg)
102        .map_err(|error| SvgRenderError(format!("could not write {}: {error}", path.display())))?;
103
104    Ok(path.to_path_buf())
105}
106
107/// Renders DOT `source` into an SVG at `path`.
108pub fn render_dot(source: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
109    write(&run(DOT_BINARY, source)?, path)
110}
111
112/// Renders `graph` into an SVG at `path`, named after the file stem.
113pub fn to_svg<G: DotGraph>(graph: &G, path: &Path) -> Result<PathBuf, SvgRenderError> {
114    let name = path
115        .file_stem()
116        .and_then(|stem| stem.to_str())
117        .unwrap_or("graph");
118
119    render_dot(&graph.to_dot(name), path)
120}
121
122/// Appends `suffix` to the filename in `prefix`. `Path::join` would make it a
123/// child component instead.
124fn suffixed(prefix: &Path, suffix: &str) -> PathBuf {
125    let mut name = OsString::from(prefix);
126    name.push(suffix);
127
128    PathBuf::from(name)
129}
130
131/// Renders each present graph as `<output_prefix>.ast.svg` and
132/// `<output_prefix>.syntax_graph.svg`. Absent layers are skipped.
133///
134/// # Errors
135///
136/// Returns [`SvgRenderError`] when Graphviz is missing or fails, or when an
137/// SVG cannot be written.
138pub fn render_graph_set(
139    graphs: &GraphSet,
140    output_prefix: &Path,
141) -> Result<Vec<PathBuf>, SvgRenderError> {
142    let mut outputs = Vec::new();
143
144    if let Some(ast) = graphs.ast.as_ref() {
145        outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
146    }
147
148    if let Some(syntax) = graphs.syntax.as_ref() {
149        outputs.push(to_svg(
150            syntax,
151            &suffixed(output_prefix, ".syntax_graph.svg"),
152        )?);
153    }
154
155    Ok(outputs)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
161    use blends_domain::ast::{AstGraph, AstNode};
162    use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
163    use blends_domain::NodeId;
164    use std::path::Path;
165
166    fn graph() -> AstGraph {
167        let mut graph = AstGraph::new();
168        graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
169        graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
170        graph.add_edge(NodeId(1), NodeId(2), 0);
171
172        graph
173    }
174
175    fn syntax_graph() -> SyntaxGraph {
176        let mut graph = SyntaxGraph::new();
177        graph.add_node(NodeId(1), SyntaxNode::File);
178        graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
179        graph.add_ast_edge(NodeId(1), NodeId(2));
180
181        graph
182    }
183
184    fn graphviz_available() -> bool {
185        run(DOT_BINARY, "digraph {}").is_ok()
186    }
187
188    #[test]
189    fn reports_a_missing_binary() {
190        let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();
191
192        assert!(error.to_string().contains("was not found"));
193    }
194
195    #[test]
196    fn reports_a_program_that_cannot_be_executed() {
197        let dir = tempfile::tempdir().unwrap();
198
199        let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();
200
201        assert!(error.to_string().contains("could not run"));
202    }
203
204    /// Floods stderr without reading stdin, with a source larger than a pipe
205    /// buffer. Hangs without the writer thread; nextest kills it at 45s.
206    #[cfg(unix)]
207    #[test]
208    fn does_not_deadlock_when_the_child_floods_stderr() {
209        use std::os::unix::fs::PermissionsExt as _;
210
211        let dir = tempfile::tempdir().unwrap();
212        let script = dir.path().join("flood");
213        std::fs::write(
214            &script,
215            "#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
216        )
217        .unwrap();
218        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
219
220        let source = format!("digraph {{\n{}}}\n", "  \"a\" -> \"b\";\n".repeat(50_000));
221
222        let error = run(script.to_str().unwrap(), &source).unwrap_err();
223
224        assert!(error.to_string().contains("exited with code 1"));
225    }
226
227    #[test]
228    fn write_creates_missing_parent_directories() {
229        let dir = tempfile::tempdir().unwrap();
230        let path = dir.path().join("nested").join("deeper").join("out.svg");
231
232        let written = super::write("<svg/>", &path).unwrap();
233
234        assert_eq!(written, path);
235        assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
236    }
237
238    /// A root path has no parent, so the write itself is what fails.
239    #[test]
240    fn write_reports_a_parentless_path() {
241        let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();
242
243        assert!(error.to_string().contains("could not write"));
244    }
245
246    #[test]
247    fn write_reports_an_unusable_parent() {
248        let dir = tempfile::tempdir().unwrap();
249        let blocked = dir.path().join("blocked");
250        std::fs::write(&blocked, "i am a file, not a directory").unwrap();
251
252        let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();
253
254        assert!(error.to_string().contains("could not create"));
255    }
256
257    #[test]
258    fn renders_a_real_svg_when_graphviz_is_available() {
259        if !graphviz_available() {
260            return;
261        }
262
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("nested").join("python_py.ast.svg");
265
266        let written = to_svg(&graph(), &path).unwrap();
267        let content = std::fs::read_to_string(&written).unwrap();
268
269        assert_eq!(written, path);
270        assert!(content.starts_with("<?xml"));
271        assert!(content.contains("<svg"));
272        assert!(content.trim_end().ends_with("</svg>"));
273    }
274
275    #[test]
276    fn suffixes_the_prefix_as_a_sibling_not_a_child() {
277        let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");
278
279        assert_eq!(path, Path::new("/out/python_py.ast.svg"));
280    }
281
282    #[test]
283    fn renders_a_graph_set_when_graphviz_is_available() {
284        if !graphviz_available() {
285            return;
286        }
287
288        let dir = tempfile::tempdir().unwrap();
289        let graphs = GraphSet {
290            ast: Some(graph()),
291            syntax: Some(syntax_graph()),
292        };
293
294        let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();
295
296        assert_eq!(
297            outputs,
298            vec![
299                dir.path().join("python_py.ast.svg"),
300                dir.path().join("python_py.syntax_graph.svg"),
301            ]
302        );
303        assert!(outputs.iter().all(|path| path.is_file()));
304    }
305
306    #[test]
307    fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
308        if !graphviz_available() {
309            return;
310        }
311
312        let dir = tempfile::tempdir().unwrap();
313        let ast_only = GraphSet {
314            ast: Some(graph()),
315            syntax: None,
316        };
317
318        let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();
319
320        assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
321        assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
322    }
323
324    #[test]
325    fn renders_nothing_for_an_empty_graph_set() {
326        let dir = tempfile::tempdir().unwrap();
327
328        let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();
329
330        assert!(outputs.is_empty());
331    }
332
333    #[test]
334    fn reports_invalid_dot_source_when_graphviz_is_available() {
335        if !graphviz_available() {
336            return;
337        }
338
339        let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();
340
341        assert!(error.to_string().contains("exited with code"));
342    }
343}