Skip to content

A control character in a label aborts the whole GraphML and Obsidian export (ANSI escape in a heading, form feed in source) #2897

Closed

Description

@abhay-codes07

Summary

A C0 control character in a node label aborts two exporters outright. The character arrives from ordinary corpus content, passes detection, extraction and the graph build without complaint, and then costs the entire export.

  • to_graphml raises ValueError: All strings must be XML compatible: Unicode or ASCII, no NULL bytes or control characters — XML 1.0 permits tab, LF and CR and no other C0 control.
  • to_obsidian raises OSError: [Errno 22] Invalid argument on Windows, which rejects control characters in a path outright. One bad label costs the whole vault, not one note.

Reproduce

One markdown file whose heading carries an ANSI colour sequence — what you get by pasting a terminal capture into docs:

(src / "notes.md").write_text(
    "# Release \x0c Notes\n\nSome text.\n\n## Build \x1b[31mlog\x1b[0m capture\n\nmore\n",
    encoding="utf-8")

Then detect → extract → build → export:

nodes=5   labels with a C0 control char: 1
    'Build \x1b[31mlog\x1b[0m capture'

  graphml   CRASH ValueError: All strings must be XML compatible: Unicode or ASCII, no NULL bytes or control characters
  cypher    OK
  json      OK
  obsidian  CRASH OSError: [Errno 22] Invalid argument
  html      OK

Narrowed to the exact character classes:

CRASH  NUL \x00 | BEL \x07 | BS \x08 | VT \x0b | FF \x0c | ESC \x1b | US \x1f
OK     TAB \x09 | LF \x0a  | CR \x0d | DEL \x7f

Why this is ordinary content, not hostile input

  • ANSI escapes (\x1b) appear whenever a terminal capture is committed into a .md/.txt and a heading includes coloured output.
  • Form feed (\x0c) is a long-standing Emacs/Python convention for a page separator inside source files.

Neither is exotic, and nothing earlier in the pipeline objects. The failure surfaces only at the very end, after the expensive work is done.

The codebase already knows

This reads as a gap rather than a deliberate choice, because the other paths handle it:

  • _cypher_escape explicitly drops C0 controls — its docstring says "First normalise: drop NUL and other C0 control chars except tab."
  • to_html routes labels through security.sanitize_label, whose docstring is "Strip control characters and cap length."

Only to_graphml and to_obsidian pass the raw label straight through to a writer that cannot take it.

Suggested fix

Sanitise at those two boundaries, matching what the other exporters already do:

  • GraphML — filter strings in the existing _graphml_safe coercion hook (the one added for the None/non-scalar cases in to_graphml crashes on dict/list-valued attributes (node metadata, graph-level hyperedges) #1831), dropping only the C0 controls XML forbids so tab/LF/CR survive. Node IDs need the same treatment: they become the id attribute of every <node> and edge endpoint, so a control character there is equally fatal.
  • Obsidian — _obsidian_safe_stem already folds CR/LF to spaces; folding the remaining C0 controls (and DEL) the same way keeps them out of a path Windows rejects.

Losing an escape character is the right cost here. Losing the export is not.

I have this working with tests and will open a PR shortly.

Environment

graphify v8 @ b14b52e (0.9.47)
Python 3.12, Windows 11

Activity

  1. safishamsi commented on Aug 20, 2026

    @safishamsi
    Member

    Fixed in v0.9.48: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.48 (a control character in a label no longer aborts the GraphML/Obsidian export). Closing as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions