From 4941ee8bc8e08eb7553d048f3e41dcafc0e137aa Mon Sep 17 00:00:00 2001 From: Matt Ivan Date: Thu, 23 Jul 2026 09:13:24 -0700 Subject: [PATCH 1/2] Build a human-friendly local hex viewer --- README.md | 6 +- docs/tools.md | 11 +- src/app/features/hexdump/HexdumpPage.tsx | 716 +++++++++++++++++++++-- src/app/layouts/AppLayout.tsx | 11 +- src/app/routes/AppRoutes.tsx | 174 ++++-- src/app/styles/index.css | 120 +++- src/app/utils/tools.ts | 16 +- src/shared/analysis/hexdump.ts | 261 ++++++++- tests/analysis/hexdump.test.ts | 75 ++- tests/app/hexdump-page.test.tsx | 36 ++ 10 files changed, 1330 insertions(+), 96 deletions(-) create mode 100644 tests/app/hexdump-page.test.tsx diff --git a/README.md b/README.md index be1db54..08ddfd2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Desktop - HTTP Signer: - URL and HTML entity encode/decode - Unicode inspection (code points, UTF-8 bytes, UTF-16 units) - JWT inspector with decode warnings and claim highlighting -- Hexdump formatter with offsets and ASCII preview +- Local-file hex viewer with byte-spectrum coloring, search/jump, paging, statistics, and synchronized ASCII - Hash + HMAC helpers via Web Crypto - Bitwise operations, endianness swap, IPv4/int conversion, timestamp conversion - Entropy, stats, frequency hints, magic byte detection @@ -161,8 +161,8 @@ pnpm preview ## Deploy to Cloudflare Workers 1. Set secrets or env vars: - - `CLOUDFLARE_API_TOKEN` - - `CLOUDFLARE_ACCOUNT_ID` + - `CLOUDFLARE_API_TOKEN` + - `CLOUDFLARE_ACCOUNT_ID` 2. Authenticate Wrangler if needed: ```bash diff --git a/docs/tools.md b/docs/tools.md index b58818e..1d186f8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -14,7 +14,16 @@ Paste unknown payloads, get format detection, warnings, entropy hints, and fast ## Inspection tools -- JWT inspector, Hexdump, Unicode explorer +- JWT inspector and Unicode explorer +- Hex Viewer: + - Open or drop local files without uploading them + - 18-group byte-spectrum coloring (leading nibble plus distinct `00` and `ff`) + - Semantic and color-free display modes + - Hex/text search, offset jump, paged rendering, byte statistics, and synchronized ASCII selection + +The byte-spectrum approach is inspired by Alice Pellerin's +[“your hex editor should color-code bytes”](https://simonomi.dev/blog/color-code-your-bytes/). + - X.509 inspector, TLS verifier, ASN.1/DER viewer - HAR inspector, Cookie analyzer, Schema validators - DNS Tools: diff --git a/src/app/features/hexdump/HexdumpPage.tsx b/src/app/features/hexdump/HexdumpPage.tsx index 3db3c7c..c039d12 100644 --- a/src/app/features/hexdump/HexdumpPage.tsx +++ b/src/app/features/hexdump/HexdumpPage.tsx @@ -1,78 +1,716 @@ -import { useMemo, useState } from 'react'; -import { formatHexdump } from '../../../shared/analysis/hexdump'; -import { textToBytes, hexToBytes } from '../../../shared/encoding'; +import { ChevronLeft, ChevronRight, Clipboard, FileUp, Search, X } from 'lucide-react'; +import { type DragEvent, type ChangeEvent, useMemo, useRef, useState } from 'react'; +import { + buildHexdumpRows, + decodeByteInput, + findByteSequence, + formatHexdump, + getByteColorToken, + summarizeBytes, + type ByteColorMode, + type ByteInputEncoding, +} from '../../../shared/analysis/hexdump'; import { useWorkspace } from '../../hooks/useWorkspace'; +const FILE_LIMIT = 32 * 1024 * 1024; +const PAGE_SIZES = [768, 3072, 12288] as const; +const MAX_SEARCH_MATCHES = 1000; + +interface LocalFileSource { + bytes: Uint8Array; + name: string; + size: number; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function formatPercent(count: number, total: number): string { + return total === 0 ? '0%' : `${((count / total) * 100).toFixed(1)}%`; +} + +function parseJumpOffset(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = /^0x/i.test(trimmed) + ? Number.parseInt(trimmed.slice(2), 16) + : /[a-f]/i.test(trimmed) + ? Number.parseInt(trimmed, 16) + : Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + export function HexdumpPage() { const { input, setInput } = useWorkspace(); + const [inputEncoding, setInputEncoding] = useState('auto'); const [bytesPerLine, setBytesPerLine] = useState(16); const [uppercase, setUppercase] = useState(false); const [offsetBase, setOffsetBase] = useState<10 | 16>(16); + const [colorMode, setColorMode] = useState('nibble'); + const [pageSize, setPageSize] = useState<(typeof PAGE_SIZES)[number]>(3072); + const [pageOffset, setPageOffset] = useState(0); + const [selectedOffset, setSelectedOffset] = useState(null); + const [localFile, setLocalFile] = useState(null); + const [sourceError, setSourceError] = useState(''); + const [dragging, setDragging] = useState(false); + const [searchInput, setSearchInput] = useState(''); + const [searchEncoding, setSearchEncoding] = useState<'hex' | 'text'>('hex'); + const [matchCursor, setMatchCursor] = useState(0); + const [jumpInput, setJumpInput] = useState(''); + const [copied, setCopied] = useState(false); + const fileInputRef = useRef(null); + + const decoded = useMemo(() => { + if (localFile) { + return { + bytes: localFile.bytes, + detectedEncoding: 'file' as const, + error: '', + }; + } + try { + const result = decodeByteInput(input, inputEncoding); + return { ...result, error: '' }; + } catch (error) { + return { + bytes: new Uint8Array(), + detectedEncoding: inputEncoding === 'auto' ? ('text' as const) : inputEncoding, + error: error instanceof Error ? error.message : 'Unable to decode input', + }; + } + }, [input, inputEncoding, localFile]); + + const { bytes } = decoded; + const options = useMemo( + () => ({ bytesPerLine, uppercase, offsetBase }), + [bytesPerLine, offsetBase, uppercase], + ); + const maxPageOffset = + bytes.length === 0 ? 0 : Math.floor((bytes.length - 1) / pageSize) * pageSize; + const visibleOffset = Math.min(pageOffset, maxPageOffset); + const pageEnd = Math.min(visibleOffset + pageSize, bytes.length); + const rows = useMemo( + () => buildHexdumpRows(bytes, options, visibleOffset, pageEnd), + [bytes, options, pageEnd, visibleOffset], + ); + const summary = useMemo(() => summarizeBytes(bytes), [bytes]); + const visibleDump = useMemo( + () => formatHexdump(bytes, options, visibleOffset, pageEnd), + [bytes, options, pageEnd, visibleOffset], + ); + + const search = useMemo(() => { + if (!searchInput) { + return { matches: [] as number[], needleLength: 0, error: '' }; + } + try { + const needle = decodeByteInput(searchInput, searchEncoding).bytes; + return { + matches: findByteSequence(bytes, needle, MAX_SEARCH_MATCHES), + needleLength: needle.length, + error: '', + }; + } catch (error) { + return { + matches: [] as number[], + needleLength: 0, + error: error instanceof Error ? error.message : 'Invalid search value', + }; + } + }, [bytes, searchEncoding, searchInput]); - const output = useMemo(() => { - let bytes: Uint8Array; + const safeMatchCursor = + search.matches.length === 0 ? 0 : Math.min(matchCursor, search.matches.length - 1); + const activeMatch = search.matches[safeMatchCursor] ?? null; + const highlightedOffsets = useMemo(() => { + const result = new Set(); + for (const start of search.matches) { + if (start + search.needleLength < visibleOffset || start >= pageEnd) { + continue; + } + for (let index = start; index < start + search.needleLength; index += 1) { + if (index >= visibleOffset && index < pageEnd) { + result.add(index); + } + } + } + return result; + }, [pageEnd, search.matches, search.needleLength, visibleOffset]); + + const focusOffset = (offset: number) => { + if (bytes.length === 0) { + return; + } + const bounded = Math.max(0, Math.min(offset, bytes.length - 1)); + setPageOffset(Math.floor(bounded / pageSize) * pageSize); + setSelectedOffset(bounded); + }; + + const moveMatch = (direction: -1 | 1) => { + if (search.matches.length === 0) { + return; + } + const next = (safeMatchCursor + direction + search.matches.length) % search.matches.length; + setMatchCursor(next); + focusOffset(search.matches[next]); + }; + + const loadFile = async (file: File) => { + setSourceError(''); + if (file.size > FILE_LIMIT) { + setSourceError( + `Files are limited to ${formatSize(FILE_LIMIT)} to keep inspection responsive.`, + ); + return; + } try { - bytes = /^[0-9a-fA-F\s]+$/.test(input.trim()) ? hexToBytes(input) : textToBytes(input); + const bytes = new Uint8Array(await file.arrayBuffer()); + setLocalFile({ bytes, name: file.name, size: file.size }); + setPageOffset(0); + setSelectedOffset(null); + setMatchCursor(0); } catch { - bytes = textToBytes(input); + setSourceError('Hexyr could not read that local file.'); + } + }; + + const onFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + void loadFile(file); + } + event.target.value = ''; + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer.files[0]; + if (file) { + void loadFile(file); } + }; - return formatHexdump(bytes, { - bytesPerLine, - uppercase, - offsetBase, - }); - }, [bytesPerLine, input, offsetBase, uppercase]); + const copyVisibleDump = async () => { + await navigator.clipboard.writeText(visibleDump); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + + const selectedByte = + selectedOffset === null || selectedOffset >= bytes.length ? null : bytes[selectedOffset]; + const pageCount = Math.max(1, Math.ceil(bytes.length / pageSize)); + const currentPage = bytes.length === 0 ? 1 : Math.floor(visibleOffset / pageSize) + 1; return (
-
-

Hex Viewer and Hexdump

-

Offsets, grouped bytes, ASCII preview, and formatting toggles.

+
+
+

Hex Viewer and Hexdump

+

+ Inspect local bytes with pattern-aware color, search, paging, and synchronized ASCII. +

+
+
Files and pasted data stay in this browser.
-