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.
133pub fn render_graph_set(
134    graphs: &GraphSet,
135    output_prefix: &Path,
136) -> Result<Vec<PathBuf>, SvgRenderError> {
137    let mut outputs = Vec::new();
138
139    if let Some(ast) = graphs.ast.as_ref() {
140        outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
141    }
142
143    if let Some(syntax) = graphs.syntax.as_ref() {
144        outputs.push(to_svg(
145            syntax,
146            &suffixed(output_prefix, ".syntax_graph.svg"),
147        )?);
148    }
149
150    Ok(outputs)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
156    use blends_domain::ast::{AstGraph, AstNode};
157    use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
158    use blends_domain::NodeId;
159    use std::path::Path;
160
161    fn graph() -> AstGraph {
162        let mut graph = AstGraph::new();
163        graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
164        graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
165        graph.add_edge(NodeId(1), NodeId(2), 0);
166
167        graph
168    }
169
170    fn syntax_graph() -> SyntaxGraph {
171        let mut graph = SyntaxGraph::new();
172        graph.add_node(NodeId(1), SyntaxNode::File);
173        graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
174        graph.add_ast_edge(NodeId(1), NodeId(2));
175
176        graph
177    }
178
179    fn graphviz_available() -> bool {
180        run(DOT_BINARY, "digraph {}").is_ok()
181    }
182
183    #[test]
184    fn reports_a_missing_binary() {
185        let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();
186
187        assert!(error.to_string().contains("was not found"));
188    }
189
190    #[test]
191    fn reports_a_program_that_cannot_be_executed() {
192        let dir = tempfile::tempdir().unwrap();
193
194        let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();
195
196        assert!(error.to_string().contains("could not run"));
197    }
198
199    /// Floods stderr without reading stdin, with a source larger than a pipe
200    /// buffer. Hangs without the writer thread; nextest kills it at 45s.
201    #[cfg(unix)]
202    #[test]
203    fn does_not_deadlock_when_the_child_floods_stderr() {
204        use std::os::unix::fs::PermissionsExt as _;
205
206        let dir = tempfile::tempdir().unwrap();
207        let script = dir.path().join("flood");
208        std::fs::write(
209            &script,
210            "#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
211        )
212        .unwrap();
213        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
214
215        let source = format!("digraph {{\n{}}}\n", "  \"a\" -> \"b\";\n".repeat(50_000));
216
217        let error = run(script.to_str().unwrap(), &source).unwrap_err();
218
219        assert!(error.to_string().contains("exited with code 1"));
220    }
221
222    #[test]
223    fn write_creates_missing_parent_directories() {
224        let dir = tempfile::tempdir().unwrap();
225        let path = dir.path().join("nested").join("deeper").join("out.svg");
226
227        let written = super::write("<svg/>", &path).unwrap();
228
229        assert_eq!(written, path);
230        assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
231    }
232
233    /// A root path has no parent, so the write itself is what fails.
234    #[test]
235    fn write_reports_a_parentless_path() {
236        let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();
237
238        assert!(error.to_string().contains("could not write"));
239    }
240
241    #[test]
242    fn write_reports_an_unusable_parent() {
243        let dir = tempfile::tempdir().unwrap();
244        let blocked = dir.path().join("blocked");
245        std::fs::write(&blocked, "i am a file, not a directory").unwrap();
246
247        let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();
248
249        assert!(error.to_string().contains("could not create"));
250    }
251
252    #[test]
253    fn renders_a_real_svg_when_graphviz_is_available() {
254        if !graphviz_available() {
255            return;
256        }
257
258        let dir = tempfile::tempdir().unwrap();
259        let path = dir.path().join("nested").join("python_py.ast.svg");
260
261        let written = to_svg(&graph(), &path).unwrap();
262        let content = std::fs::read_to_string(&written).unwrap();
263
264        assert_eq!(written, path);
265        assert!(content.starts_with("<?xml"));
266        assert!(content.contains("<svg"));
267        assert!(content.trim_end().ends_with("</svg>"));
268    }
269
270    #[test]
271    fn suffixes_the_prefix_as_a_sibling_not_a_child() {
272        let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");
273
274        assert_eq!(path, Path::new("/out/python_py.ast.svg"));
275    }
276
277    #[test]
278    fn renders_a_graph_set_when_graphviz_is_available() {
279        if !graphviz_available() {
280            return;
281        }
282
283        let dir = tempfile::tempdir().unwrap();
284        let graphs = GraphSet {
285            ast: Some(graph()),
286            syntax: Some(syntax_graph()),
287        };
288
289        let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();
290
291        assert_eq!(
292            outputs,
293            vec![
294                dir.path().join("python_py.ast.svg"),
295                dir.path().join("python_py.syntax_graph.svg"),
296            ]
297        );
298        assert!(outputs.iter().all(|path| path.is_file()));
299    }
300
301    #[test]
302    fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
303        if !graphviz_available() {
304            return;
305        }
306
307        let dir = tempfile::tempdir().unwrap();
308        let ast_only = GraphSet {
309            ast: Some(graph()),
310            syntax: None,
311        };
312
313        let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();
314
315        assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
316        assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
317    }
318
319    #[test]
320    fn renders_nothing_for_an_empty_graph_set() {
321        let dir = tempfile::tempdir().unwrap();
322
323        let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();
324
325        assert!(outputs.is_empty());
326    }
327
328    #[test]
329    fn reports_invalid_dot_source_when_graphviz_is_available() {
330        if !graphviz_available() {
331            return;
332        }
333
334        let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();
335
336        assert!(error.to_string().contains("exited with code"));
337    }
338}