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