Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
here`, or the rule's own `description`, instead of quoting the whole object and the
subschema it must not match.

### Fixed

- **`fluid test` and `fluid contract-validation` print the contract's text as written.**
`fluid test` read its report as Rich markup, so a JSON Schema pattern such as
`^[a-z0-9][a-z0-9-]*[a-z0-9]$` printed as `^*$` and `exposes[orders]` lost its id; in
both commands an id holding `[/]` crashed the command with a `MarkupError` traceback.
The soda engine's check names and stderr, the `โŒ` lines printed by the CLI's shared
error helper, and the `Unexpected error:` line print as written too, so an exception
that quotes `[/]` no longer ends in a second traceback. That line, the `Provider
error:` line and the `--debug` traceback are now redacted, as the `โŒ` lines already
were. In all of these a `:name:` code stays text
(`fe80::a:b:c` showed an emoji) and a control character prints as `\x1b` and the like,
so an escape sequence in a contract or an error message can no longer clear the
terminal or plant a link. JSON and JUnit output are unchanged.

## [0.21.0] โ€” 2026-10-19

### Upgrade notes
Expand Down
60 changes: 55 additions & 5 deletions fluid_build/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

try:
from rich.console import Console as RichConsole
from rich.text import Text as RichText

RICH_AVAILABLE = True
except ImportError: # pragma: no cover
Expand All @@ -58,6 +59,34 @@
_SECRET_OUTPUT_PATTERN = re.compile(
r"(?i)\b(password|passphrase|token|secret|api[_ -]?key)\b(\s*[:=]\s*)([^\s,;]+)"
)
# The control characters (Unicode Cc) a terminal acts on: ESC opens an escape
# sequence, and the C1 range holds the one-byte CSI and OSC. Printed raw, a contract
# value or an exception message could clear the screen, retitle the window or show a
# link that opens another URL, and a carriage return lets the rest of a line print
# over its start. Tab and line feed stay; a CRLF line break reads as a line feed.
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")


def _show_controls(text: str) -> str:
"""``text`` with each control character spelled ``\\xNN`` instead of sent to the terminal.

Redact before calling this, not after: a spelled control ends in a hex digit, a
word character, so ``\\x00PASSWORD=`` no longer matches the redaction pattern.
"""
return _CONTROL_CHARS.sub(lambda m: f"\\x{ord(m.group()):02x}", text.replace("\r\n", "\n"))


def literal_text(text: Any, style: str = "") -> RichText:
"""``text`` as a Rich ``Text``: printed as written, control characters spelled out.

For data a renderer quotes back, such as a contract value or an exception message.
Rich parses a ``str`` as markup, so a lowercase ``[word]`` vanishes and ``[/]``
raises ``MarkupError``, and it swaps a ``:name:`` code for its emoji
(``fe80::a:b:c`` gained a ๐Ÿ…ฑ). A ``Text`` gets neither. ``rich.markup.escape`` is
no substitute: it doubles a trailing backslash when no tag follows it. Rich must
be installed.
"""
return RichText(_show_controls(str(text)), style=style)


def _redact_str(value: str) -> str:
Expand Down Expand Up @@ -162,6 +191,18 @@ def cprint_json(document: str, **kwargs: Any) -> None:
stream.flush()


def cprint_literal(text: Any, **kwargs: Any) -> None:
""":func:`cprint` for data: no markup, no ``:emoji:`` codes, control characters spelled.

The ``str`` counterpart of :func:`literal_text`, for a line a renderer quotes back
(a check name, a tool's stderr). It stays a ``str`` so :func:`cprint` still redacts
it; the raw text is redacted first, as in :func:`error`. ``markup`` and ``emoji``
are always off, whatever ``kwargs`` says.
"""
kwargs.update(markup=False, emoji=False)
cprint(_show_controls(_redact_str(str(text))), **kwargs)


def info(msg: str) -> None:
"""Informational message (cyan prefix)."""
cprint(f"[cyan]โ„น[/cyan] {msg}")
Expand All @@ -178,13 +219,22 @@ def warning(msg: str) -> None:


def error(msg: str) -> None:
"""Error message (red โŒ) โ€“ written to *stderr*."""
# Funnel through the typed ``_redact_str`` sanitiser so CodeQL
# recognises the sink path is sanitised on every branch.
safe_msg = _redact_str(str(msg))
"""Error message (red โŒ) โ€“ written to *stderr*.

``msg`` is printed literally, never parsed as Rich markup. An error
quotes data: a contract id, a schema ``pattern``, an exception message.
Wrapped in ``[red]...[/red]``, a lowercase ``[word]`` in it vanished and
a ``[/]`` raised ``MarkupError``, which escaped the CLI's last-resort
handler as a traceback because that handler reports through this
function. Control characters are spelled out (see :func:`literal_text`).
"""
# Redact the raw text first (see ``_show_controls``), and funnel through the
# typed ``_redact_str`` sanitiser last so CodeQL recognises the sink path is
# sanitised on every branch.
safe_msg = _redact_str(_show_controls(_redact_str(str(msg))))
if RICH_AVAILABLE:
_err_console = RichConsole(stderr=True)
_err_console.print(f"[red]โŒ {safe_msg}[/red]")
_err_console.print(RichText(f"โŒ {safe_msg}", style="red"))
else:
print(f"โŒ {safe_msg}", file=sys.stderr)

Expand Down
13 changes: 10 additions & 3 deletions fluid_build/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,16 @@ def main(argv: Optional[List[str]] = None) -> int:
raise

except Exception as e: # noqa: BLE001
# The exception text is data, not markup, as in the CLIError branch
# above. Parsed as markup, an error quoting ``[/]`` raised
# ``MarkupError`` from this handler, and the outer fallback then
# died on the same text with a traceback. It is redacted too: that
# crash used to keep a secret beside a ``[/]`` off the screen.
from fluid_build._console import _redact_str, literal_text

# Structured catch for provider errors (before generic Exception)
if ProviderError is not None and isinstance(e, ProviderError):
context.console.print(f"[red]โŒ Provider error: {e}[/red]")
context.console.print(literal_text(_redact_str(f"โŒ Provider error: {e}"), "red"))
cli.logger.log_safe(
"error",
"Provider error",
Expand All @@ -532,7 +539,7 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1

# Handle unexpected errors with enhanced context
context.console.print(f"[red]โŒ Unexpected error: {e}[/red]")
context.console.print(literal_text(_redact_str(f"โŒ Unexpected error: {e}"), "red"))
cli.logger.log_safe(
"error",
"CLI unhandled exception",
Expand All @@ -548,7 +555,7 @@ def main(argv: Optional[List[str]] = None) -> int:
import traceback

context.console.print("\n[red]Debug traceback:[/red]")
context.console.print(traceback.format_exc())
context.console.print(literal_text(_redact_str(traceback.format_exc())))

# Provide helpful guidance
context.console.print("\n[yellow]๐Ÿ’ก This appears to be an unexpected error.[/yellow]")
Expand Down
4 changes: 4 additions & 0 deletions fluid_build/cli/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
console,
cprint,
cprint_json,
cprint_literal,
detail,
error,
heading,
hint,
info,
literal_text,
success,
warning,
)
Expand All @@ -57,11 +59,13 @@
"console",
"cprint",
"cprint_json",
"cprint_literal",
"detail",
"error",
"heading",
"hint",
"info",
"literal_text",
"success",
"warning",
]
37 changes: 23 additions & 14 deletions fluid_build/cli/contract_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional

from fluid_build.cli.console import cprint, cprint_json, success
from fluid_build.cli.console import cprint, cprint_json, cprint_literal, literal_text, success
from fluid_build.cli.console import error as console_error

# Rich imports for enhanced output
Expand Down Expand Up @@ -1616,25 +1616,31 @@ def output_text_report(report: ValidationReport, output_file: Optional[str] = No


def output_rich_report(report: ValidationReport, output_file: Optional[str] = None) -> None:
"""Output validation report using Rich formatting."""
"""Output validation report using Rich formatting.

The contract's own values (its id and version, every issue message and
path, a rule's severity) go in through ``literal_text``, never as markup:
an id holding ``[/]`` raised ``MarkupError``, and a JSON Schema
``pattern`` lost its ``[a-z0-9]`` classes.
"""
console = Console()

# Summary panel
status_color = "green" if report.is_valid() else "red"
status_icon = "โœ…" if report.is_valid() else "โŒ"

summary_text = f"""[bold]{status_icon} Contract Validation: {report.contract_id}[/bold]
Version: {report.contract_version}
Validated: {report.exposes_validated} exposed, {report.consumes_validated} consumed
Results: {report.checks_passed} passed, {report.checks_failed} failed
Duration: {report.duration:.2f}s"""
summary_text = Text.assemble(
literal_text(f"{status_icon} Contract Validation: {report.contract_id}", "bold"),
literal_text(f"\nVersion: {report.contract_version}"),
f"\nValidated: {report.exposes_validated} exposed, {report.consumes_validated} consumed",
f"\nResults: {report.checks_passed} passed, {report.checks_failed} failed",
f"\nDuration: {report.duration:.2f}s",
)

console.print(Panel(summary_text, title="Validation Summary", border_style=status_color))

# Issues table
if report.issues:
from rich.markup import escape

table = Table(title="Validation Issues", show_header=True)
table.add_column("Severity", style="bold")
table.add_column("Category")
Expand All @@ -1646,12 +1652,13 @@ def output_rich_report(report: ValidationReport, output_file: Optional[str] = No
"error": "[red]ERROR[/red]",
"warning": "[yellow]WARNING[/yellow]",
"info": "[blue]INFO[/blue]",
}.get(issue.severity, issue.severity.upper())
}.get(issue.severity) or literal_text(issue.severity.upper())

# Messages quote contract text (an overlay warning names the
# contract's ids), which Rich would parse as markup.
table.add_row(
severity_style, issue.category, escape(issue.message), escape(issue.path or "")
severity_style,
issue.category,
literal_text(issue.message),
literal_text(issue.path or ""),
)

console.print(table)
Expand Down Expand Up @@ -1693,7 +1700,9 @@ def output_plain_report(report: ValidationReport, output_file: Optional[str] = N
f.write(report_text)
success(f"Report saved to: {output_file}")
else:
cprint(report_text)
# The report quotes contract text; as markup, the console's Rich-less
# fallback strips an ``exposes[orders]``-style ``[word]`` as a style tag.
cprint_literal(report_text)


def output_json_report(report: ValidationReport, output_file: Optional[str] = None) -> None:
Expand Down
Loading
Loading