diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 2d0c3418ea2..4cf3198cb40 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -59,7 +59,7 @@ def _read_message_body( return sys.stdin.read() try: return Path(file_path).read_text(encoding="utf-8") - except OSError as exc: + except (OSError, UnicodeDecodeError) as exc: print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) sys.exit(_USAGE_EXIT) diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index 9202315e3d4..802cff88c90 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch): assert "cannot read" in err.lower() +def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + bad = tmp_path / "bad-bytes.bin" + bad.write_bytes(b"\xff\xfe\x00") + + args = _parse(["--to", "telegram", "--file", str(bad)]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "cannot read" in err.lower() + + def test_tool_error_returns_failure_exit(monkeypatch, capsys): import sys as _sys import types as _types diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 566d1e41cf6..047ad67912f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -115,6 +115,13 @@ describe('ANSI sanitizers', () => { expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`) }) + it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => { + const sample = `A${ESC}(0B${ESC}%GC${ESC})0D` + + expect(stripAnsi(sample)).toBe('ABCD') + expect(sanitizeAnsiForRender(sample)).toBe('ABCD') + }) + it('detects non-CSI escape prefixes too', () => { expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) }) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 5a5bdce603d..ef3a1816975 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -15,6 +15,7 @@ const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g') const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g') const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g const WS_RE = /\s+/g @@ -26,6 +27,7 @@ export const stripAnsi = (s: string) => .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_CSI_RE, '') .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') .replace(ANSI_STRAY_ESC_RE, '') .replace(CONTROL_RE, '') @@ -36,6 +38,7 @@ export const sanitizeAnsiForRender = (s: string) => .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') .replace(ANSI_STRAY_ESC_RE, '') .replace(CONTROL_RE, '')