fluidattacks-blends 0.6.0

Blends imperative shell: parsing, AST-graph construction, serialization
Documentation
//! DOT source through Graphviz into an SVG file. Development-only: it shells
//! out to `dot`, which the production scanner does not ship.

use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;

use blends_domain::graph_set::GraphSet;

use crate::dot::DotGraph;

const DOT_BINARY: &str = "dot";

#[derive(Clone, PartialEq, Eq, Debug)]
pub struct SvgRenderError(String);

impl fmt::Display for SvgRenderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for SvgRenderError {}

fn missing_binary() -> SvgRenderError {
    SvgRenderError(format!(
        "The '{DOT_BINARY}' executable was not found. Graph rendering is a development-only \
         feature: install Graphviz (brew install graphviz, apt install graphviz) or use the \
         blends development environment"
    ))
}

/// Feeds `source` to `program` on stdin and returns the SVG it writes out.
/// Fixed arguments and no shell, so stdin is the only caller-supplied value.
fn run(program: &str, source: &str) -> Result<String, SvgRenderError> {
    let mut child = Command::new(program)
        .arg("-Tsvg")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| match error.kind() {
            std::io::ErrorKind::NotFound => missing_binary(),
            _ => SvgRenderError(format!("could not run {program}: {error}")),
        })?;

    let mut stdin = child
        .stdin
        .take()
        .ok_or_else(|| SvgRenderError(format!("{program} exposed no stdin")))?;

    // Write stdin on a thread so the output pipes are drained meanwhile. Writing
    // inline deadlocks against a child that fills stdout or stderr first.
    // Scoped so the thread borrows `source` instead of copying it.
    let (output, written) = thread::scope(|scope| {
        // `move` hands over `stdin`, closing the pipe when the thread ends.
        let writer = scope.spawn(move || stdin.write_all(source.as_bytes()));
        // Drain before joining, or the deadlock is back.
        let output = child.wait_with_output();

        (output, writer.join())
    });

    let output = output
        .map_err(|error| SvgRenderError(format!("could not read from {program}: {error}")))?;

    if !output.status.success() {
        let code = output
            .status
            .code()
            .map_or_else(|| "signal".to_owned(), |code| code.to_string());
        let stderr = String::from_utf8_lossy(&output.stderr);

        // Before the writer error: a child that left early broke the pipe, so
        // the exit status is the real diagnosis.
        return Err(SvgRenderError(format!(
            "{DOT_BINARY} exited with code {code}: {}",
            stderr.trim()
        )));
    }

    written
        .map_err(|_| SvgRenderError(format!("the {program} writer thread panicked")))?
        .map_err(|error| SvgRenderError(format!("could not write to {program}: {error}")))?;

    String::from_utf8(output.stdout)
        .map_err(|error| SvgRenderError(format!("{program} emitted invalid utf-8: {error}")))
}

fn write(svg: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            SvgRenderError(format!("could not create {}: {error}", parent.display()))
        })?;
    }

    fs::write(path, svg)
        .map_err(|error| SvgRenderError(format!("could not write {}: {error}", path.display())))?;

    Ok(path.to_path_buf())
}

/// Renders DOT `source` into an SVG at `path`.
pub fn render_dot(source: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
    write(&run(DOT_BINARY, source)?, path)
}

/// Renders `graph` into an SVG at `path`, named after the file stem.
pub fn to_svg<G: DotGraph>(graph: &G, path: &Path) -> Result<PathBuf, SvgRenderError> {
    let name = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("graph");

    render_dot(&graph.to_dot(name), path)
}

/// Appends `suffix` to the filename in `prefix`. `Path::join` would make it a
/// child component instead.
fn suffixed(prefix: &Path, suffix: &str) -> PathBuf {
    let mut name = OsString::from(prefix);
    name.push(suffix);

    PathBuf::from(name)
}

/// Renders each present graph as `<output_prefix>.ast.svg` and
/// `<output_prefix>.syntax_graph.svg`. Absent layers are skipped.
pub fn render_graph_set(
    graphs: &GraphSet,
    output_prefix: &Path,
) -> Result<Vec<PathBuf>, SvgRenderError> {
    let mut outputs = Vec::new();

    if let Some(ast) = graphs.ast.as_ref() {
        outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
    }

    if let Some(syntax) = graphs.syntax.as_ref() {
        outputs.push(to_svg(
            syntax,
            &suffixed(output_prefix, ".syntax_graph.svg"),
        )?);
    }

    Ok(outputs)
}

#[cfg(test)]
mod tests {
    use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
    use blends_domain::ast::{AstGraph, AstNode};
    use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
    use blends_domain::NodeId;
    use std::path::Path;

    fn graph() -> AstGraph {
        let mut graph = AstGraph::new();
        graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
        graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
        graph.add_edge(NodeId(1), NodeId(2), 0);

        graph
    }

    fn syntax_graph() -> SyntaxGraph {
        let mut graph = SyntaxGraph::new();
        graph.add_node(NodeId(1), SyntaxNode::File);
        graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
        graph.add_ast_edge(NodeId(1), NodeId(2));

        graph
    }

    fn graphviz_available() -> bool {
        run(DOT_BINARY, "digraph {}").is_ok()
    }

    #[test]
    fn reports_a_missing_binary() {
        let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();

        assert!(error.to_string().contains("was not found"));
    }

    #[test]
    fn reports_a_program_that_cannot_be_executed() {
        let dir = tempfile::tempdir().unwrap();

        let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();

        assert!(error.to_string().contains("could not run"));
    }

    /// Floods stderr without reading stdin, with a source larger than a pipe
    /// buffer. Hangs without the writer thread; nextest kills it at 45s.
    #[cfg(unix)]
    #[test]
    fn does_not_deadlock_when_the_child_floods_stderr() {
        use std::os::unix::fs::PermissionsExt as _;

        let dir = tempfile::tempdir().unwrap();
        let script = dir.path().join("flood");
        std::fs::write(
            &script,
            "#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();

        let source = format!("digraph {{\n{}}}\n", "  \"a\" -> \"b\";\n".repeat(50_000));

        let error = run(script.to_str().unwrap(), &source).unwrap_err();

        assert!(error.to_string().contains("exited with code 1"));
    }

    #[test]
    fn write_creates_missing_parent_directories() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("deeper").join("out.svg");

        let written = super::write("<svg/>", &path).unwrap();

        assert_eq!(written, path);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
    }

    /// A root path has no parent, so the write itself is what fails.
    #[test]
    fn write_reports_a_parentless_path() {
        let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();

        assert!(error.to_string().contains("could not write"));
    }

    #[test]
    fn write_reports_an_unusable_parent() {
        let dir = tempfile::tempdir().unwrap();
        let blocked = dir.path().join("blocked");
        std::fs::write(&blocked, "i am a file, not a directory").unwrap();

        let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();

        assert!(error.to_string().contains("could not create"));
    }

    #[test]
    fn renders_a_real_svg_when_graphviz_is_available() {
        if !graphviz_available() {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("python_py.ast.svg");

        let written = to_svg(&graph(), &path).unwrap();
        let content = std::fs::read_to_string(&written).unwrap();

        assert_eq!(written, path);
        assert!(content.starts_with("<?xml"));
        assert!(content.contains("<svg"));
        assert!(content.trim_end().ends_with("</svg>"));
    }

    #[test]
    fn suffixes_the_prefix_as_a_sibling_not_a_child() {
        let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");

        assert_eq!(path, Path::new("/out/python_py.ast.svg"));
    }

    #[test]
    fn renders_a_graph_set_when_graphviz_is_available() {
        if !graphviz_available() {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let graphs = GraphSet {
            ast: Some(graph()),
            syntax: Some(syntax_graph()),
        };

        let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();

        assert_eq!(
            outputs,
            vec![
                dir.path().join("python_py.ast.svg"),
                dir.path().join("python_py.syntax_graph.svg"),
            ]
        );
        assert!(outputs.iter().all(|path| path.is_file()));
    }

    #[test]
    fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
        if !graphviz_available() {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let ast_only = GraphSet {
            ast: Some(graph()),
            syntax: None,
        };

        let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();

        assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
        assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
    }

    #[test]
    fn renders_nothing_for_an_empty_graph_set() {
        let dir = tempfile::tempdir().unwrap();

        let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();

        assert!(outputs.is_empty());
    }

    #[test]
    fn reports_invalid_dot_source_when_graphviz_is_available() {
        if !graphviz_available() {
            return;
        }

        let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();

        assert!(error.to_string().contains("exited with code"));
    }
}