From 19b5e31ac02c28ca3fe184b51a87bc828f8e8cc2 Mon Sep 17 00:00:00 2001 From: Donghyun Kim Date: Tue, 26 May 2026 08:43:24 +0900 Subject: [PATCH] Make MermaidRenderer plugin-agnostic via TreeGrid path_depth Address review feedback that the renderer was tightly coupled to plugins exposing PID/PPID columns: the previous implementation looked up "PID" and "PPID" by name to build parent->child edges and raised a generic exception otherwise, which prevented any non-pstree tree plugin from being rendered as Mermaid. The relationship is already encoded in the TreeGrid -- every TreeNode carries its path_depth -- so the new render() walks the rows in traversal order and tracks ancestry with a parent stack: * descending one or more levels pushes the previously-emitted node once per level (so a level skip still produces sane pops); * ascending pops the corresponding number of levels; * the stack top is always the parent of the next emitted node, or empty for a root-level node. Each node is given a stable per-render identifier (n1, n2, ...) instead of being keyed by a column value such as PID, since PIDs are not unique across a TreeGrid and may contain characters that are unsafe in Mermaid node IDs. A small label-escaping helper replaces the previous ad-hoc string replacement of parentheses, and embedded newlines in cell renderings are folded to
so each row stays a single Mermaid node. The unused tree_indent_column placeholder (flagged by code scanning) is dropped as part of the rewrite. --- volatility3/cli/text_renderer.py | 123 +++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 40 deletions(-) diff --git a/volatility3/cli/text_renderer.py b/volatility3/cli/text_renderer.py index 1b22be19f..1d5cf3f19 100644 --- a/volatility3/cli/text_renderer.py +++ b/volatility3/cli/text_renderer.py @@ -639,13 +639,37 @@ class MermaidRenderer(CLIRenderer): name = "mermaid" structured_output = True + @staticmethod + def _mermaid_label(text: str) -> str: + """Escape a value for use inside a Mermaid node label (``["..."]``). + + Double quotes terminate the label, so they must be replaced with the + Mermaid-supported entity. Newlines inside cell renderings are + converted to ``
`` so each row remains a single Mermaid node. + """ + return text.replace('"', """).replace("\n", "
") + def get_render_options(self): pass def render(self, grid: interfaces.renderers.TreeGrid) -> None: - """Renders each column immediately to stdout. + """Render the TreeGrid as a Mermaid ``graph TD`` flowchart. - This does not format each line's width appropriately, it merely tab separates each field + The renderer is plugin-agnostic: it derives the parent/child + relationship from each node's ``path_depth`` in the grid, rather + than from any particular column (such as PID/PPID). This means + any tree-shaped plugin output -- pstree, vadwalk, handles tree, + future plugins -- renders without modification. + + The algorithm maintains a parent stack while walking the rows in + traversal order: + + * descending one or more levels pushes the previously-emitted + node onto the stack (once per level descended) so it becomes + the current parent; + * ascending pops the same number of levels off the stack; + * the top of the stack is always the parent of the next emitted + node, or empty for a root-level node. Args: grid: The TreeGrid object to render @@ -654,51 +678,70 @@ class MermaidRenderer(CLIRenderer): sys.stderr.write("Formatting...\n") - tree_indent_column = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) # Tree Signature - + def format_row(node: interfaces.renderers.TreeNode) -> str: + """Build a Mermaid node label from every column of ``node``.""" + cells = [] + for column_index, column in enumerate(grid.columns): + renderer = self._type_renderers.get( + column.type, self._type_renderers['default'] + ) + value = renderer(node.values[column_index]) + cells.append(f"{column.name}:{self._mermaid_label(value)}") + return "
".join(cells) + + rows: List[Tuple[int, str]] = [] + def visitor( node: interfaces.renderers.TreeNode, - accumulator: List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]] - ) -> List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]]: - # Nodes always have a path value, giving them a path_depth of at least 1, we use max just in case - line = {} - for column_index in range(len(grid.columns)): - column = grid.columns[column_index] - renderer = self._type_renderers.get(column.type, self._type_renderers['default']) - data = renderer(node.values[column_index]) - line[column] = data.split("\n") - accumulator.append((node.path_depth, line)) + accumulator: List[Tuple[int, str]], + ) -> List[Tuple[int, str]]: + accumulator.append((node.path_depth, format_row(node))) return accumulator - final_output: List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]] = [] - if not grid.populated: - grid.populate(visitor, final_output) + grid.populate(visitor, rows) else: - grid.visit(node = None, function = visitor, initial_accumulator = final_output) + grid.visit(node=None, function=visitor, initial_accumulator=rows) - column_titles = [""] + [column.name for column in grid.columns] + # Stable, unique per-node IDs. We never reuse a column value (e.g. + # PID) because (a) PID is not guaranteed unique across a TreeGrid, + # (b) it is plugin-specific, and (c) Mermaid IDs must avoid + # characters like parentheses that may appear in column data. + node_counter = 0 - own_column = ["PID"] - parent_column = ["PPID"] + def next_id() -> str: + nonlocal node_counter + node_counter += 1 + return f"n{node_counter}" - if not((set(own_column).issubset(column_titles)) and (set(parent_column).issubset(column_titles))): - raise Exception("Plugin cannot be rendered as mermaid because there is no tree relationship.") - - tree_header = "graph TD\n" - branch_data = f"{tree_header}" + parent_stack: List[str] = [] + prev_depth = 0 + prev_id: Optional[str] = None - for (_depth, line) in final_output: - nums_line = max([len(line[column]) for column in line]) - for column in line: - line[column] = line[column] + ([""] * (nums_line - len(line[column]))) - for index in range(nums_line): - node_data = "" - for column in grid.columns: - node_data += f"{column.name}:{line[column][index]}
" - if(column.name in own_column): - own = line[column][index] - if(column.name in parent_column): - parent = line[column][index] - branch_data += f"\t{parent} --> {own}[{node_data}]\n".replace("(", "").replace(")", "") - outfd.write("{}\n".format(branch_data)) + lines: List[str] = ["graph TD"] + for depth, label in rows: + node_id = next_id() + if prev_id is not None: + if depth > prev_depth: + # Descended one or more levels. Push prev_id once per + # level so subsequent pops align even when the tree + # skips levels (e.g. depth 1 -> depth 3). + for _ in range(depth - prev_depth): + parent_stack.append(prev_id) + elif depth < prev_depth: + for _ in range(prev_depth - depth): + if parent_stack: + parent_stack.pop() + # depth == prev_depth: sibling, keep the same parent + + if parent_stack: + parent = parent_stack[-1] + lines.append(f'\t{parent} --> {node_id}["{label}"]') + else: + # Root-level node: declare it on its own. + lines.append(f'\t{node_id}["{label}"]') + + prev_id = node_id + prev_depth = depth + + outfd.write("\n".join(lines) + "\n")