fix(review): address Copilot follow-up on sanitizer and file decode errors

Consume multi-byte non-CSI ESC sequences during ANSI sanitization and handle UnicodeDecodeError for `hermes send --file` so review findings are resolved without regressions.
This commit is contained in:
Brooklyn Nicholson 2026-05-16 23:00:58 -05:00
parent 7e1788db5d
commit a65f723e68
4 changed files with 24 additions and 1 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)
})

View file

@ -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, '')