Release Binary Tools 0.2.0
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
name: Verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: Select declared npm version
|
||||||
|
run: npm install --global npm@11.17.0
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Audit runtime dependencies
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
- name: Check, test, and build
|
||||||
|
run: npm run check
|
||||||
|
- name: Install browser engines
|
||||||
|
run: npx playwright install --with-deps chromium firefox webkit
|
||||||
|
- name: Browser tests
|
||||||
|
run: npm run test:browser
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-09-02
|
||||||
|
|
||||||
|
- Add bounded byte editing, binary search and reusable structure templates in
|
||||||
|
an isolated worker, with explicit offset and interpretation evidence.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial local-first binary conversion and byte inspection workbench.
|
- Initial local-first binary conversion and byte inspection workbench.
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ Convert, inspect and decode byte-oriented formats locally in the browser. Binary
|
|||||||
|
|
||||||
- Synchronized UTF-8, hexadecimal, Base64, RFC 4648 Base32 and Bitcoin-alphabet Base58 conversions.
|
- Synchronized UTF-8, hexadecimal, Base64, RFC 4648 Base32 and Bitcoin-alphabet Base58 conversions.
|
||||||
- A paged byte/offset/ASCII inspector with clickable highlighted source ranges.
|
- A paged byte/offset/ASCII inspector with clickable highlighted source ranges.
|
||||||
|
- Checked insert, replace and delete operations over the canonical byte buffer.
|
||||||
|
- Exact UTF-8 and hex search, including `??` byte wildcards, capped result sets,
|
||||||
|
and a cancellable hard-deadline worker path for larger buffers.
|
||||||
|
- Little- and big-endian integer, floating-point, timestamp and UTF-8
|
||||||
|
interpretations at any checked offset.
|
||||||
|
- Named range bookmarks plus audited PNG, RIFF/WAVE and ELF header templates.
|
||||||
- CBOR and MessagePack JSON encode/decode.
|
- CBOR and MessagePack JSON encode/decode.
|
||||||
- Strict ASN.1 DER TLV trees, including canonical identifiers, lengths and core primitive encodings.
|
- Strict ASN.1 DER TLV trees, including canonical identifiers, lengths and core primitive encodings.
|
||||||
- Protocol Buffers wire inspection with optional JSON field schemas for common scalar and nested message types.
|
- Protocol Buffers wire inspection with optional JSON field schemas for common scalar and nested message types.
|
||||||
@@ -13,7 +19,7 @@ Convert, inspect and decode byte-oriented formats locally in the browser. Binary
|
|||||||
|
|
||||||
## Safety and limits
|
## Safety and limits
|
||||||
|
|
||||||
All input is inert data. The application makes no external requests and never evaluates decoded content. Inputs and encoded outputs are capped at 1 MiB; structured parsing is capped at depth 64 and 10,000 nodes. Base58 is capped at 4 KiB because arbitrary-base conversion is quadratic. DER rejects indefinite/non-minimal lengths and identifiers plus malformed core primitive encodings. Protobuf groups are deliberately unsupported. Schema assistance validates field records and wire compatibility, but is intentionally limited and is not a generated protobuf runtime.
|
All input is inert data. The application makes no external requests and never evaluates decoded content. Inputs, edits and encoded outputs are capped at 1 MiB; search patterns at 4 KiB, matches at 10,000, bookmarks at 100 and template fields at 128. Larger searches run in a disposable local worker with cooperative cancellation and a five-second hard deadline. Structured parsing is capped at depth 64 and 10,000 nodes. Base58 is capped at 4 KiB because arbitrary-base conversion is quadratic. DER rejects indefinite/non-minimal lengths and identifiers plus malformed core primitive encodings. Protobuf groups are deliberately unsupported. Templates inspect only declared fields and do not claim full format validation. Schema assistance validates field records and wire compatibility, but is intentionally limited and is not a generated protobuf runtime.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -26,7 +32,7 @@ npm run test:browser
|
|||||||
npm run package:release -- --force
|
npm run package:release -- --force
|
||||||
```
|
```
|
||||||
|
|
||||||
The deterministic release is written to `release/binary-tools-0.1.0.zip` with a SHA-256 sidecar.
|
The deterministic release is written to `release/binary-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Binary Tools
|
- Project: Binary Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Repository: https://git.add-ideas.de/lotobo/binary-tools
|
- Repository: https://git.add-ideas.de/lotobo/binary-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||||
- Artifact: `binary-tools-0.1.0.zip`
|
- Artifact: `binary-tools-0.2.0.zip`
|
||||||
|
|
||||||
The generated `toolbox-app.json` repeats this version and source identity. Dependencies are exactly pinned by `package-lock.json`; bundled runtime licence texts are included in `LICENSES/npm-runtime-licenses.txt`.
|
The generated `toolbox-app.json` repeats this version and source identity. Dependencies are exactly pinned by `package-lock.json`; bundled runtime licence texts are included in `LICENSES/npm-runtime-licenses.txt`.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Accessibility
|
# Accessibility
|
||||||
|
|
||||||
Controls use native labels, buttons, selects and dialogs. Inspector formats expose tab semantics, parsing failures use alert regions, byte ranges have textual offsets, and every byte has an accessible label. Layout reflows below 62rem while wide byte rows remain horizontally scrollable. The shared shell provides system/light/dark themes and visible keyboard focus.
|
Controls use native labels, buttons, selects and dialogs. Inspector formats use a labelled pressed-state button group, parsing failures use alert regions, byte ranges have textual offsets, and every byte has an accessible label. Layout reflows below 62rem while wide byte rows remain horizontally scrollable. The shared shell provides system/light/dark themes and visible keyboard focus.
|
||||||
|
|||||||
Generated
+22
-21
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "binary-tools",
|
"name": "binary-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "binary-tools",
|
"name": "binary-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@msgpack/msgpack": "3.1.3",
|
"@msgpack/msgpack": "3.1.3",
|
||||||
"cbor-x": "1.6.4",
|
"cbor-x": "1.6.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -44,24 +44,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
"license": "GPL-3.0-or-later",
|
||||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
"engines": {
|
||||||
"license": "GPL-3.0-or-later"
|
"node": ">=22"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -69,13 +70,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "binary-tools",
|
"name": "binary-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert, inspect and decode binary data locally in the browser.",
|
"description": "Convert, inspect and decode binary data locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,16 +39,16 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@msgpack/msgpack": "3.1.3",
|
"@msgpack/msgpack": "3.1.3",
|
||||||
"cbor-x": "1.6.4",
|
"cbor-x": "1.6.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
{
|
||||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
name: "chromium",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
testIgnore: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile-chromium",
|
||||||
|
testMatch: /responsive\.spec\.ts/,
|
||||||
|
use: { ...devices["Pixel 5"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-09-02
|
||||||
|
|
||||||
|
- Add bounded byte editing, binary search and reusable structure templates in
|
||||||
|
an isolated worker, with explicit offset and interpretation evidence.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial local-first binary conversion and byte inspection workbench.
|
- Initial local-first binary conversion and byte inspection workbench.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
|||||||
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-helpers@0.1.0
|
@add-ideas/toolbox-helpers@0.2.0
|
||||||
Declared licence: GPL-3.0-or-later
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
|||||||
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-shell-react@0.2.3
|
@add-ideas/toolbox-shell-react@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+8
-2
@@ -6,6 +6,12 @@ Convert, inspect and decode byte-oriented formats locally in the browser. Binary
|
|||||||
|
|
||||||
- Synchronized UTF-8, hexadecimal, Base64, RFC 4648 Base32 and Bitcoin-alphabet Base58 conversions.
|
- Synchronized UTF-8, hexadecimal, Base64, RFC 4648 Base32 and Bitcoin-alphabet Base58 conversions.
|
||||||
- A paged byte/offset/ASCII inspector with clickable highlighted source ranges.
|
- A paged byte/offset/ASCII inspector with clickable highlighted source ranges.
|
||||||
|
- Checked insert, replace and delete operations over the canonical byte buffer.
|
||||||
|
- Exact UTF-8 and hex search, including `??` byte wildcards, capped result sets,
|
||||||
|
and a cancellable hard-deadline worker path for larger buffers.
|
||||||
|
- Little- and big-endian integer, floating-point, timestamp and UTF-8
|
||||||
|
interpretations at any checked offset.
|
||||||
|
- Named range bookmarks plus audited PNG, RIFF/WAVE and ELF header templates.
|
||||||
- CBOR and MessagePack JSON encode/decode.
|
- CBOR and MessagePack JSON encode/decode.
|
||||||
- Strict ASN.1 DER TLV trees, including canonical identifiers, lengths and core primitive encodings.
|
- Strict ASN.1 DER TLV trees, including canonical identifiers, lengths and core primitive encodings.
|
||||||
- Protocol Buffers wire inspection with optional JSON field schemas for common scalar and nested message types.
|
- Protocol Buffers wire inspection with optional JSON field schemas for common scalar and nested message types.
|
||||||
@@ -13,7 +19,7 @@ Convert, inspect and decode byte-oriented formats locally in the browser. Binary
|
|||||||
|
|
||||||
## Safety and limits
|
## Safety and limits
|
||||||
|
|
||||||
All input is inert data. The application makes no external requests and never evaluates decoded content. Inputs and encoded outputs are capped at 1 MiB; structured parsing is capped at depth 64 and 10,000 nodes. Base58 is capped at 4 KiB because arbitrary-base conversion is quadratic. DER rejects indefinite/non-minimal lengths and identifiers plus malformed core primitive encodings. Protobuf groups are deliberately unsupported. Schema assistance validates field records and wire compatibility, but is intentionally limited and is not a generated protobuf runtime.
|
All input is inert data. The application makes no external requests and never evaluates decoded content. Inputs, edits and encoded outputs are capped at 1 MiB; search patterns at 4 KiB, matches at 10,000, bookmarks at 100 and template fields at 128. Larger searches run in a disposable local worker with cooperative cancellation and a five-second hard deadline. Structured parsing is capped at depth 64 and 10,000 nodes. Base58 is capped at 4 KiB because arbitrary-base conversion is quadratic. DER rejects indefinite/non-minimal lengths and identifiers plus malformed core primitive encodings. Protobuf groups are deliberately unsupported. Templates inspect only declared fields and do not claim full format validation. Schema assistance validates field records and wire compatibility, but is intentionally limited and is not a generated protobuf runtime.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -26,7 +32,7 @@ npm run test:browser
|
|||||||
npm run package:release -- --force
|
npm run package:release -- --force
|
||||||
```
|
```
|
||||||
|
|
||||||
The deterministic release is written to `release/binary-tools-0.1.0.zip` with a SHA-256 sidecar.
|
The deterministic release is written to `release/binary-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Binary Tools
|
- Project: Binary Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Repository: https://git.add-ideas.de/lotobo/binary-tools
|
- Repository: https://git.add-ideas.de/lotobo/binary-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||||
- Artifact: `binary-tools-0.1.0.zip`
|
- Artifact: `binary-tools-0.2.0.zip`
|
||||||
|
|
||||||
The generated `toolbox-app.json` repeats this version and source identity. Dependencies are exactly pinned by `package-lock.json`; bundled runtime licence texts are included in `LICENSES/npm-runtime-licenses.txt`.
|
The generated `toolbox-app.json` repeats this version and source identity. Dependencies are exactly pinned by `package-lock.json`; bundled runtime licence texts are included in `LICENSES/npm-runtime-licenses.txt`.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Accessibility
|
# Accessibility
|
||||||
|
|
||||||
Controls use native labels, buttons, selects and dialogs. Inspector formats expose tab semantics, parsing failures use alert regions, byte ranges have textual offsets, and every byte has an accessible label. Layout reflows below 62rem while wide byte rows remain horizontally scrollable. The shared shell provides system/light/dark themes and visible keyboard focus.
|
Controls use native labels, buttons, selects and dialogs. Inspector formats use a labelled pressed-state button group, parsing failures use alert regions, byte ranges have textual offsets, and every byte has an accessible label. Layout reflows below 62rem while wide byte rows remain horizontally scrollable. The shared shell provides system/light/dark themes and visible keyboard focus.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "binary-tools-shell-";
|
const CACHE_PREFIX = "binary-tools-shell-";
|
||||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+14
-1
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.binary-tools",
|
"id": "de.add-ideas.binary-tools",
|
||||||
"name": "Binary Tools",
|
"name": "Binary Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert, inspect and decode bytes locally.",
|
"description": "Convert, inspect and decode bytes locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -29,6 +29,19 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"accepts": [
|
||||||
|
{ "mediaType": "*/*", "extensions": [] },
|
||||||
|
{ "mediaType": "application/octet-stream", "extensions": [".bin"] },
|
||||||
|
{ "mediaType": "text/plain", "extensions": [".txt", ".hex"] }
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
{ "mediaType": "application/octet-stream", "extensions": [".bin"] },
|
||||||
|
{ "mediaType": "text/plain", "extensions": [".txt", ".hex"] },
|
||||||
|
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": { "required": [], "optional": ["workers"] },
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ import {
|
|||||||
parseSchema,
|
parseSchema,
|
||||||
type ByteNode,
|
type ByteNode,
|
||||||
} from "../core/inspect";
|
} from "../core/inspect";
|
||||||
|
import { searchBytesInWorker } from "../core/binary-jobs";
|
||||||
|
import {
|
||||||
|
applyBinaryTemplate,
|
||||||
|
BINARY_TEMPLATES,
|
||||||
|
findBytePattern,
|
||||||
|
interpretBytes,
|
||||||
|
parseReplacementHex,
|
||||||
|
parseSearchPattern,
|
||||||
|
replaceByteRange,
|
||||||
|
validateBookmarks,
|
||||||
|
type ByteBookmark,
|
||||||
|
type ByteSearchResult,
|
||||||
|
type Endianness,
|
||||||
|
} from "../core/workbench";
|
||||||
|
|
||||||
type Inspector = "bytes" | "cbor" | "msgpack" | "der" | "protobuf";
|
type Inspector = "bytes" | "cbor" | "msgpack" | "der" | "protobuf";
|
||||||
const EXAMPLE = new TextEncoder().encode("Hello, binary world!\n");
|
const EXAMPLE = new TextEncoder().encode("Hello, binary world!\n");
|
||||||
@@ -35,7 +49,23 @@ export function Workbench() {
|
|||||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||||
const [bytePage, setBytePage] = useState(0);
|
const [bytePage, setBytePage] = useState(0);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [editOffset, setEditOffset] = useState("0");
|
||||||
|
const [editDeleteLength, setEditDeleteLength] = useState("0");
|
||||||
|
const [editHex, setEditHex] = useState("");
|
||||||
|
const [interpretOffset, setInterpretOffset] = useState("0");
|
||||||
|
const [endianness, setEndianness] = useState<Endianness>("little");
|
||||||
|
const [searchEncoding, setSearchEncoding] = useState<"hex" | "utf8">("hex");
|
||||||
|
const [searchSource, setSearchSource] = useState("48 65 ?? 6c 6f");
|
||||||
|
const [searchResult, setSearchResult] = useState<ByteSearchResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [searchStatus, setSearchStatus] = useState("");
|
||||||
|
const [searchBusy, setSearchBusy] = useState(false);
|
||||||
|
const [bookmarks, setBookmarks] = useState<ByteBookmark[]>([]);
|
||||||
|
const [bookmarkName, setBookmarkName] = useState("");
|
||||||
|
const [templateId, setTemplateId] = useState(BINARY_TEMPLATES[0]?.id ?? "");
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const searchAbort = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const views = useMemo(
|
const views = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -81,6 +111,29 @@ export function Workbench() {
|
|||||||
const visibleBytePage = Math.min(bytePage, bytePageCount - 1);
|
const visibleBytePage = Math.min(bytePage, bytePageCount - 1);
|
||||||
const bytePageStart = visibleBytePage * BYTE_PAGE_SIZE;
|
const bytePageStart = visibleBytePage * BYTE_PAGE_SIZE;
|
||||||
const bytePageEnd = Math.min(bytes.length, bytePageStart + BYTE_PAGE_SIZE);
|
const bytePageEnd = Math.min(bytes.length, bytePageStart + BYTE_PAGE_SIZE);
|
||||||
|
const interpretations = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
values: interpretBytes(bytes, parseOffset(interpretOffset), endianness),
|
||||||
|
error: "",
|
||||||
|
};
|
||||||
|
} catch (caught) {
|
||||||
|
return {
|
||||||
|
values: [],
|
||||||
|
error: caught instanceof Error ? caught.message : String(caught),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [bytes, endianness, interpretOffset]);
|
||||||
|
const template = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return { result: applyBinaryTemplate(bytes, templateId), error: "" };
|
||||||
|
} catch (caught) {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
error: caught instanceof Error ? caught.message : String(caught),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [bytes, templateId]);
|
||||||
|
|
||||||
function selectRange(range: { start: number; end: number }): void {
|
function selectRange(range: { start: number; end: number }): void {
|
||||||
setSelection(range);
|
setSelection(range);
|
||||||
@@ -94,6 +147,7 @@ export function Workbench() {
|
|||||||
setBytes(next);
|
setBytes(next);
|
||||||
setSelection({ start: 0, end: 0 });
|
setSelection({ start: 0, end: 0 });
|
||||||
setBytePage(0);
|
setBytePage(0);
|
||||||
|
setSearchResult(null);
|
||||||
setError("");
|
setError("");
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
setError(caught instanceof Error ? caught.message : String(caught));
|
||||||
@@ -124,6 +178,8 @@ export function Workbench() {
|
|||||||
setSource(bytesToHex(next));
|
setSource(bytesToHex(next));
|
||||||
setSelection({ start: 0, end: 0 });
|
setSelection({ start: 0, end: 0 });
|
||||||
setBytePage(0);
|
setBytePage(0);
|
||||||
|
setBookmarks([]);
|
||||||
|
setSearchResult(null);
|
||||||
setError("");
|
setError("");
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
setError(caught instanceof Error ? caught.message : String(caught));
|
||||||
@@ -148,6 +204,97 @@ export function Workbench() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitBytes(
|
||||||
|
next: Uint8Array,
|
||||||
|
nextSelection: { start: number; end: number },
|
||||||
|
): void {
|
||||||
|
setBytes(next);
|
||||||
|
setEncoding("hex");
|
||||||
|
setSource(bytesToHex(next));
|
||||||
|
setSelection(nextSelection);
|
||||||
|
setBytePage(
|
||||||
|
nextSelection.start < next.length
|
||||||
|
? Math.floor(nextSelection.start / BYTE_PAGE_SIZE)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
setBookmarks((current) =>
|
||||||
|
current.filter(
|
||||||
|
(bookmark) => bookmark.offset + bookmark.length <= next.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSearchResult(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEdit(): void {
|
||||||
|
try {
|
||||||
|
const offset = parseOffset(editOffset);
|
||||||
|
const deleteLength = parseOffset(editDeleteLength);
|
||||||
|
const replacement = parseReplacementHex(editHex);
|
||||||
|
const next = replaceByteRange(bytes, offset, deleteLength, replacement);
|
||||||
|
commitBytes(next, { start: offset, end: offset + replacement.length });
|
||||||
|
setError("");
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
searchAbort.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
searchAbort.current = controller;
|
||||||
|
setSearchBusy(true);
|
||||||
|
try {
|
||||||
|
const pattern = parseSearchPattern(searchSource, searchEncoding);
|
||||||
|
setSearchStatus("Searching…");
|
||||||
|
const next =
|
||||||
|
bytes.length >= 64 * 1024 && typeof Worker !== "undefined"
|
||||||
|
? await searchBytesInWorker(bytes, pattern, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: ({ scannedBytes, totalBytes }) =>
|
||||||
|
setSearchStatus(
|
||||||
|
`Searching ${scannedBytes.toLocaleString()} of ${totalBytes.toLocaleString()} bytes…`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: findBytePattern(bytes, pattern);
|
||||||
|
setSearchResult(next);
|
||||||
|
setSearchStatus(
|
||||||
|
`${next.offsets.length.toLocaleString()} match${next.offsets.length === 1 ? "" : "es"}${next.truncated ? " (result limit reached)" : ""}.`,
|
||||||
|
);
|
||||||
|
setError("");
|
||||||
|
} catch (caught) {
|
||||||
|
if (!(caught instanceof DOMException && caught.name === "AbortError"))
|
||||||
|
setError(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
setSearchStatus("Search stopped.");
|
||||||
|
} finally {
|
||||||
|
if (searchAbort.current === controller) searchAbort.current = null;
|
||||||
|
setSearchBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBookmark(): void {
|
||||||
|
try {
|
||||||
|
const offset = selection.start;
|
||||||
|
const length = Math.max(0, selection.end - selection.start);
|
||||||
|
const next = validateBookmarks(
|
||||||
|
[
|
||||||
|
...bookmarks,
|
||||||
|
{
|
||||||
|
id: `bookmark-${Date.now()}-${bookmarks.length}`,
|
||||||
|
name: bookmarkName || `Offset ${offset}`,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bytes.length,
|
||||||
|
);
|
||||||
|
setBookmarks(next);
|
||||||
|
setBookmarkName("");
|
||||||
|
setError("");
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="workbench">
|
<main className="workbench">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
@@ -214,6 +361,7 @@ export function Workbench() {
|
|||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
|
aria-label="Open binary file"
|
||||||
onChange={(event) => void importFile(event.target.files?.[0])}
|
onChange={(event) => void importFile(event.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,6 +382,286 @@ export function Workbench() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="workspace-grid">
|
||||||
|
<section className="panel" aria-labelledby="editing-title">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Immutable bounded operations</p>
|
||||||
|
<h2 id="editing-title">Hex editor</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
Replace, insert or delete a checked byte range. An empty replacement
|
||||||
|
deletes; a zero delete length inserts.
|
||||||
|
</p>
|
||||||
|
<div className="input-grid">
|
||||||
|
<label>
|
||||||
|
Offset (decimal or 0x…)
|
||||||
|
<input
|
||||||
|
value={editOffset}
|
||||||
|
onChange={(event) => setEditOffset(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bytes to delete
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={editDeleteLength}
|
||||||
|
onChange={(event) => setEditDeleteLength(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="full-label">
|
||||||
|
Replacement bytes (hex)
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
value={editHex}
|
||||||
|
onChange={(event) => setEditHex(event.target.value)}
|
||||||
|
placeholder="de ad be ef"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={applyEdit}
|
||||||
|
>
|
||||||
|
Apply byte edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditOffset(String(selection.start));
|
||||||
|
setEditDeleteLength(
|
||||||
|
String(Math.max(0, selection.end - selection.start)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use selected range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Exact and wildcard patterns</p>
|
||||||
|
<h3>Search bytes</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="input-grid">
|
||||||
|
<label>
|
||||||
|
Pattern syntax
|
||||||
|
<select
|
||||||
|
value={searchEncoding}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearchEncoding(event.target.value as "hex" | "utf8")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="hex">Hex (?? wildcard)</option>
|
||||||
|
<option value="utf8">UTF-8 text</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Pattern
|
||||||
|
<input
|
||||||
|
value={searchSource}
|
||||||
|
onChange={(event) => setSearchSource(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={searchBusy}
|
||||||
|
onClick={() => void search()}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!searchBusy}
|
||||||
|
onClick={() => searchAbort.current?.abort()}
|
||||||
|
>
|
||||||
|
Cancel search
|
||||||
|
</button>
|
||||||
|
<span className="muted" role="status" aria-live="polite">
|
||||||
|
{searchStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{searchResult && searchResult.offsets.length > 0 && (
|
||||||
|
<div className="button-row" aria-label="Search matches">
|
||||||
|
{searchResult.offsets.slice(0, 200).map((offset) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={offset}
|
||||||
|
onClick={() =>
|
||||||
|
selectRange({ start: offset, end: offset + 1 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
0x{offset.toString(16)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{searchResult.offsets.length > 200 && (
|
||||||
|
<span className="muted">Showing the first 200 matches.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel" aria-labelledby="typed-title">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Numbers, text and structure</p>
|
||||||
|
<h2 id="typed-title">Typed interpretations</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="input-grid">
|
||||||
|
<label>
|
||||||
|
Offset (decimal or 0x…)
|
||||||
|
<input
|
||||||
|
value={interpretOffset}
|
||||||
|
onChange={(event) => setInterpretOffset(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Byte order
|
||||||
|
<select
|
||||||
|
value={endianness}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEndianness(event.target.value as Endianness)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="little">Little-endian</option>
|
||||||
|
<option value="big">Big-endian</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{interpretations.error ? (
|
||||||
|
<p className="error">{interpretations.error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="file-table" tabIndex={0}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Bytes</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{interpretations.values.map((item) => (
|
||||||
|
<tr key={item.type}>
|
||||||
|
<td>{item.type}</td>
|
||||||
|
<td>{item.bytes}</td>
|
||||||
|
<td>
|
||||||
|
<code>{item.value}</code>
|
||||||
|
{item.meaning && <small>{item.meaning}</small>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<label>
|
||||||
|
Built-in binary template
|
||||||
|
<select
|
||||||
|
value={templateId}
|
||||||
|
onChange={(event) => setTemplateId(event.target.value)}
|
||||||
|
>
|
||||||
|
{BINARY_TEMPLATES.map((item) => (
|
||||||
|
<option value={item.id} key={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{template.error && <p className="muted">{template.error}</p>}
|
||||||
|
{template.result && (
|
||||||
|
<div className="file-table" tabIndex={0}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Offset</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{template.result.fields.map((field) => (
|
||||||
|
<tr key={`${field.offset}-${field.name}`}>
|
||||||
|
<td>{field.name}</td>
|
||||||
|
<td>{field.offset}</td>
|
||||||
|
<td>
|
||||||
|
<code>{field.value}</code>
|
||||||
|
{field.matchesExpectation === false && (
|
||||||
|
<small>Expected {field.expected}</small>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Remember source ranges</p>
|
||||||
|
<h3>Bookmarks</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<input
|
||||||
|
aria-label="Bookmark name"
|
||||||
|
value={bookmarkName}
|
||||||
|
onChange={(event) => setBookmarkName(event.target.value)}
|
||||||
|
placeholder="Header field"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={addBookmark}>
|
||||||
|
Bookmark selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bookmarks.length > 0 && (
|
||||||
|
<ul className="tree">
|
||||||
|
{bookmarks.map((bookmark) => (
|
||||||
|
<li key={bookmark.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
selectRange({
|
||||||
|
start: bookmark.offset,
|
||||||
|
end: bookmark.offset + bookmark.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<strong>{bookmark.name}</strong>
|
||||||
|
<span>
|
||||||
|
{bookmark.offset} · {bookmark.length} bytes
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${bookmark.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
setBookmarks((current) =>
|
||||||
|
current.filter((item) => item.id !== bookmark.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="panel" aria-labelledby="conversions-title">
|
<section className="panel" aria-labelledby="conversions-title">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -268,17 +696,13 @@ export function Workbench() {
|
|||||||
<h2 id="inspector-title">Inspector</h2>
|
<h2 id="inspector-title">Inspector</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mode-tabs" role="group" aria-label="Inspector format">
|
||||||
className="mode-tabs"
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Inspector format"
|
|
||||||
>
|
|
||||||
{(["bytes", "cbor", "msgpack", "der", "protobuf"] as const).map(
|
{(["bytes", "cbor", "msgpack", "der", "protobuf"] as const).map(
|
||||||
(mode) => (
|
(mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={inspector === mode}
|
aria-pressed={inspector === mode}
|
||||||
className={inspector === mode ? "active" : ""}
|
className={inspector === mode ? "active" : ""}
|
||||||
onClick={() => setInspector(mode)}
|
onClick={() => setInspector(mode)}
|
||||||
>
|
>
|
||||||
@@ -459,3 +883,15 @@ function Tree({
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOffset(source: string): number {
|
||||||
|
const value = source.trim();
|
||||||
|
if (!/^(?:0x[0-9a-f]+|\d+)$/iu.test(value))
|
||||||
|
throw new TypeError(
|
||||||
|
"Offset must be a non-negative decimal or 0x hexadecimal integer.",
|
||||||
|
);
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0)
|
||||||
|
throw new RangeError("Offset is outside the safe integer range.");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ownedBytes, startWorkerJob } from "@add-ideas/toolbox-helpers";
|
||||||
|
import { MAX_BYTES } from "./encoding";
|
||||||
|
import type {
|
||||||
|
BinaryWorkerPayload,
|
||||||
|
BinaryWorkerProgress,
|
||||||
|
} from "./binary.worker";
|
||||||
|
import type { ByteSearchPattern, ByteSearchResult } from "./workbench";
|
||||||
|
|
||||||
|
export function searchBytesInWorker(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
pattern: ByteSearchPattern,
|
||||||
|
options: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maximumMatches?: number;
|
||||||
|
onProgress?: (progress: BinaryWorkerProgress) => void;
|
||||||
|
} = {},
|
||||||
|
): Promise<ByteSearchResult> {
|
||||||
|
const worker = new Worker(new URL("./binary.worker.ts", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "binary-tools-search",
|
||||||
|
});
|
||||||
|
const owned = ownedBytes(bytes, MAX_BYTES);
|
||||||
|
return startWorkerJob<
|
||||||
|
BinaryWorkerPayload,
|
||||||
|
ByteSearchResult,
|
||||||
|
BinaryWorkerProgress,
|
||||||
|
string
|
||||||
|
>(
|
||||||
|
worker,
|
||||||
|
{
|
||||||
|
operation: "search",
|
||||||
|
bytes: owned,
|
||||||
|
pattern,
|
||||||
|
...(options.maximumMatches === undefined
|
||||||
|
? {}
|
||||||
|
: { maximumMatches: options.maximumMatches }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: options.signal,
|
||||||
|
timeoutMs: options.timeoutMs ?? 5_000,
|
||||||
|
onProgress: options.onProgress,
|
||||||
|
deserializeError: (message) => new Error(message),
|
||||||
|
workerFailureMessage: "Binary search worker stopped unexpectedly.",
|
||||||
|
},
|
||||||
|
).promise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createWorkerJobMessageHandler } from "@add-ideas/toolbox-helpers";
|
||||||
|
import {
|
||||||
|
SEARCH_MATCH_LIMIT,
|
||||||
|
type ByteSearchPattern,
|
||||||
|
type ByteSearchResult,
|
||||||
|
} from "./workbench";
|
||||||
|
|
||||||
|
export interface BinaryWorkerPayload {
|
||||||
|
operation: "search";
|
||||||
|
bytes: Uint8Array;
|
||||||
|
pattern: ByteSearchPattern;
|
||||||
|
maximumMatches?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryWorkerProgress {
|
||||||
|
scannedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerScope {
|
||||||
|
postMessage(message: unknown): void;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = self as unknown as WorkerScope;
|
||||||
|
|
||||||
|
scope.onmessage = createWorkerJobMessageHandler<
|
||||||
|
BinaryWorkerPayload,
|
||||||
|
ByteSearchResult,
|
||||||
|
BinaryWorkerProgress,
|
||||||
|
string
|
||||||
|
>(
|
||||||
|
async (payload, context) => {
|
||||||
|
if (payload.operation !== "search")
|
||||||
|
throw new TypeError("Unsupported binary worker operation.");
|
||||||
|
const maximumMatches = payload.maximumMatches ?? SEARCH_MATCH_LIMIT;
|
||||||
|
const pattern = payload.pattern;
|
||||||
|
if (
|
||||||
|
pattern.values.length === 0 ||
|
||||||
|
pattern.values.length !== pattern.masks.length ||
|
||||||
|
pattern.values.length > 4_096
|
||||||
|
)
|
||||||
|
throw new TypeError("Invalid worker search pattern.");
|
||||||
|
const offsets: number[] = [];
|
||||||
|
const lastStart = payload.bytes.length - pattern.values.length;
|
||||||
|
for (let offset = 0; offset <= lastStart; offset += 1) {
|
||||||
|
if ((offset & 0xffff) === 0) {
|
||||||
|
context.throwIfCancelled();
|
||||||
|
context.report({
|
||||||
|
scannedBytes: offset,
|
||||||
|
totalBytes: payload.bytes.length,
|
||||||
|
});
|
||||||
|
// Yield so a queued cancellation message can abort the cooperative job.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
context.throwIfCancelled();
|
||||||
|
}
|
||||||
|
let matches = true;
|
||||||
|
for (let index = 0; index < pattern.values.length; index += 1) {
|
||||||
|
const mask = pattern.masks[index] ?? 0;
|
||||||
|
if (
|
||||||
|
((payload.bytes[offset + index] ?? 0) & mask) !==
|
||||||
|
(pattern.values[index]! & mask)
|
||||||
|
) {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matches) continue;
|
||||||
|
offsets.push(offset);
|
||||||
|
if (offsets.length >= maximumMatches) {
|
||||||
|
const scannedBytes = Math.min(
|
||||||
|
payload.bytes.length,
|
||||||
|
offset + pattern.values.length,
|
||||||
|
);
|
||||||
|
return { offsets, truncated: offset < lastStart, scannedBytes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.report({
|
||||||
|
scannedBytes: payload.bytes.length,
|
||||||
|
totalBytes: payload.bytes.length,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
offsets,
|
||||||
|
truncated: false,
|
||||||
|
scannedBytes: payload.bytes.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(message) => scope.postMessage(message),
|
||||||
|
{
|
||||||
|
serializeError: (error) =>
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import {
|
||||||
|
ByteCursor,
|
||||||
|
checkedByteRange,
|
||||||
|
checkedOffsetAdd,
|
||||||
|
ownedBytes,
|
||||||
|
} from "@add-ideas/toolbox-helpers";
|
||||||
|
import { decodeInput, MAX_BYTES } from "./encoding";
|
||||||
|
|
||||||
|
export const SEARCH_MATCH_LIMIT = 10_000;
|
||||||
|
export const BOOKMARK_LIMIT = 100;
|
||||||
|
export const TEMPLATE_FIELD_LIMIT = 128;
|
||||||
|
|
||||||
|
export type Endianness = "little" | "big";
|
||||||
|
|
||||||
|
export interface ByteSearchPattern {
|
||||||
|
source: string;
|
||||||
|
values: number[];
|
||||||
|
masks: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByteSearchResult {
|
||||||
|
offsets: number[];
|
||||||
|
truncated: boolean;
|
||||||
|
scannedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
maximumMatches?: number;
|
||||||
|
onProgress?: (scannedBytes: number, totalBytes: number) => void;
|
||||||
|
shouldCancel?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByteBookmark {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateFieldType =
|
||||||
|
| "uint8"
|
||||||
|
| "uint16"
|
||||||
|
| "uint24"
|
||||||
|
| "uint32"
|
||||||
|
| "uint64"
|
||||||
|
| "int8"
|
||||||
|
| "int16"
|
||||||
|
| "int32"
|
||||||
|
| "float32"
|
||||||
|
| "float64"
|
||||||
|
| "ascii"
|
||||||
|
| "hex";
|
||||||
|
|
||||||
|
export interface TemplateFieldDefinition {
|
||||||
|
name: string;
|
||||||
|
offset: number;
|
||||||
|
type: TemplateFieldType;
|
||||||
|
length?: number;
|
||||||
|
endianness?: Endianness;
|
||||||
|
expected?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
minimumBytes: number;
|
||||||
|
fields: TemplateFieldDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFieldResult extends TemplateFieldDefinition {
|
||||||
|
byteLength: number;
|
||||||
|
value: string;
|
||||||
|
matchesExpectation: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateResult {
|
||||||
|
templateId: string;
|
||||||
|
name: string;
|
||||||
|
recognized: boolean;
|
||||||
|
fields: TemplateFieldResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypedInterpretation {
|
||||||
|
type: string;
|
||||||
|
bytes: number;
|
||||||
|
value: string;
|
||||||
|
meaning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BINARY_TEMPLATES: readonly BinaryTemplate[] = Object.freeze([
|
||||||
|
{
|
||||||
|
id: "png-header",
|
||||||
|
name: "PNG header",
|
||||||
|
description: "PNG signature and the first IHDR dimensions.",
|
||||||
|
minimumBytes: 24,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "signature",
|
||||||
|
offset: 0,
|
||||||
|
type: "hex",
|
||||||
|
length: 8,
|
||||||
|
expected: "89504e470d0a1a0a",
|
||||||
|
},
|
||||||
|
{ name: "IHDR length", offset: 8, type: "uint32" },
|
||||||
|
{
|
||||||
|
name: "chunk type",
|
||||||
|
offset: 12,
|
||||||
|
type: "ascii",
|
||||||
|
length: 4,
|
||||||
|
expected: "IHDR",
|
||||||
|
},
|
||||||
|
{ name: "width", offset: 16, type: "uint32" },
|
||||||
|
{ name: "height", offset: 20, type: "uint32" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "riff-wave",
|
||||||
|
name: "RIFF/WAVE header",
|
||||||
|
description: "RIFF container identity, declared size and WAVE form type.",
|
||||||
|
minimumBytes: 12,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "container",
|
||||||
|
offset: 0,
|
||||||
|
type: "ascii",
|
||||||
|
length: 4,
|
||||||
|
expected: "RIFF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared size",
|
||||||
|
offset: 4,
|
||||||
|
type: "uint32",
|
||||||
|
endianness: "little",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "form type",
|
||||||
|
offset: 8,
|
||||||
|
type: "ascii",
|
||||||
|
length: 4,
|
||||||
|
expected: "WAVE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "elf-ident",
|
||||||
|
name: "ELF identification",
|
||||||
|
description: "ELF magic, class, byte order and ABI identity.",
|
||||||
|
minimumBytes: 16,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "magic",
|
||||||
|
offset: 0,
|
||||||
|
type: "hex",
|
||||||
|
length: 4,
|
||||||
|
expected: "7f454c46",
|
||||||
|
},
|
||||||
|
{ name: "class", offset: 4, type: "uint8" },
|
||||||
|
{ name: "byte order", offset: 5, type: "uint8" },
|
||||||
|
{ name: "ELF version", offset: 6, type: "uint8" },
|
||||||
|
{ name: "OS ABI", offset: 7, type: "uint8" },
|
||||||
|
{ name: "ABI version", offset: 8, type: "uint8" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function replaceByteRange(
|
||||||
|
input: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
deleteLength: number,
|
||||||
|
replacement: Uint8Array,
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
const source = ownedBytes(input, MAX_BYTES);
|
||||||
|
const range = checkedByteRange(
|
||||||
|
source.length,
|
||||||
|
offset,
|
||||||
|
deleteLength,
|
||||||
|
"Edit range",
|
||||||
|
);
|
||||||
|
const nextLength = checkedOffsetAdd(
|
||||||
|
source.length - range.length,
|
||||||
|
replacement.length,
|
||||||
|
MAX_BYTES,
|
||||||
|
"Edited byte length",
|
||||||
|
);
|
||||||
|
const output = new Uint8Array(nextLength);
|
||||||
|
output.set(source.subarray(0, range.offset));
|
||||||
|
output.set(replacement, range.offset);
|
||||||
|
output.set(source.subarray(range.end), range.offset + replacement.length);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBytes(
|
||||||
|
input: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
return replaceByteRange(input, offset, 0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBytes(
|
||||||
|
input: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
length: number,
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
return replaceByteRange(input, offset, length, new Uint8Array());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchPattern(
|
||||||
|
source: string,
|
||||||
|
encoding: "hex" | "utf8",
|
||||||
|
): ByteSearchPattern {
|
||||||
|
if (encoding === "utf8") {
|
||||||
|
const values = [...new TextEncoder().encode(source)];
|
||||||
|
if (values.length === 0) throw new TypeError("Search pattern is empty.");
|
||||||
|
if (values.length > 4_096)
|
||||||
|
throw new RangeError("Search patterns are limited to 4 KiB.");
|
||||||
|
return { source, values, masks: values.map(() => 0xff) };
|
||||||
|
}
|
||||||
|
const compact = source.replace(/\s+/gu, "");
|
||||||
|
if (!compact) throw new TypeError("Search pattern is empty.");
|
||||||
|
if (compact.length % 2 !== 0)
|
||||||
|
throw new SyntaxError("Hex search patterns need complete byte pairs.");
|
||||||
|
const pairs = compact.match(/.{2}/gu) ?? [];
|
||||||
|
if (pairs.length > 4_096)
|
||||||
|
throw new RangeError("Search patterns are limited to 4 KiB.");
|
||||||
|
const values: number[] = [];
|
||||||
|
const masks: number[] = [];
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (pair === "??") {
|
||||||
|
values.push(0);
|
||||||
|
masks.push(0);
|
||||||
|
} else if (/^[0-9a-fA-F]{2}$/u.test(pair)) {
|
||||||
|
values.push(Number.parseInt(pair, 16));
|
||||||
|
masks.push(0xff);
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(
|
||||||
|
`Invalid hex search byte ${pair}; use two hex digits or ??.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { source, values, masks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBytePattern(
|
||||||
|
input: Uint8Array,
|
||||||
|
pattern: ByteSearchPattern,
|
||||||
|
options: SearchOptions = {},
|
||||||
|
): ByteSearchResult {
|
||||||
|
const bytes = ownedBytes(input, MAX_BYTES);
|
||||||
|
validateSearchPattern(pattern);
|
||||||
|
const maximumMatches = options.maximumMatches ?? SEARCH_MATCH_LIMIT;
|
||||||
|
if (!Number.isSafeInteger(maximumMatches) || maximumMatches < 1)
|
||||||
|
throw new TypeError("Maximum matches must be a positive safe integer.");
|
||||||
|
const offsets: number[] = [];
|
||||||
|
const lastStart = bytes.length - pattern.values.length;
|
||||||
|
for (let offset = 0; offset <= lastStart; offset += 1) {
|
||||||
|
if ((offset & 0xffff) === 0) {
|
||||||
|
if (options.shouldCancel?.()) throw createAbortError();
|
||||||
|
options.onProgress?.(offset, bytes.length);
|
||||||
|
}
|
||||||
|
let matches = true;
|
||||||
|
for (let index = 0; index < pattern.values.length; index += 1) {
|
||||||
|
const mask = pattern.masks[index] ?? 0;
|
||||||
|
if (
|
||||||
|
((bytes[offset + index] ?? 0) & mask) !==
|
||||||
|
(pattern.values[index]! & mask)
|
||||||
|
) {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matches) continue;
|
||||||
|
offsets.push(offset);
|
||||||
|
if (offsets.length >= maximumMatches) {
|
||||||
|
const scannedBytes = Math.min(
|
||||||
|
bytes.length,
|
||||||
|
offset + pattern.values.length,
|
||||||
|
);
|
||||||
|
options.onProgress?.(scannedBytes, bytes.length);
|
||||||
|
return { offsets, truncated: offset < lastStart, scannedBytes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.onProgress?.(bytes.length, bytes.length);
|
||||||
|
return { offsets, truncated: false, scannedBytes: bytes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSearchPattern(pattern: ByteSearchPattern): void {
|
||||||
|
if (
|
||||||
|
pattern.values.length === 0 ||
|
||||||
|
pattern.values.length !== pattern.masks.length ||
|
||||||
|
pattern.values.length > 4_096 ||
|
||||||
|
pattern.values.some(
|
||||||
|
(value) => !Number.isInteger(value) || value < 0 || value > 255,
|
||||||
|
) ||
|
||||||
|
pattern.masks.some((value) => value !== 0 && value !== 0xff)
|
||||||
|
)
|
||||||
|
throw new TypeError("Invalid bounded byte-search pattern.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpretBytes(
|
||||||
|
input: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
endianness: Endianness,
|
||||||
|
): TypedInterpretation[] {
|
||||||
|
checkedByteRange(input.length, offset, 0, "Interpretation offset");
|
||||||
|
const results: TypedInterpretation[] = [];
|
||||||
|
const little = endianness === "little";
|
||||||
|
const add = (
|
||||||
|
type: string,
|
||||||
|
length: number,
|
||||||
|
read: (cursor: ByteCursor) => number | bigint,
|
||||||
|
meaning?: (value: number | bigint) => string | undefined,
|
||||||
|
) => {
|
||||||
|
if (input.length - offset < length) return;
|
||||||
|
const cursor = new ByteCursor(input, {
|
||||||
|
byteOffset: offset,
|
||||||
|
littleEndian: little,
|
||||||
|
maximumBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
const value = read(cursor);
|
||||||
|
results.push({
|
||||||
|
type,
|
||||||
|
bytes: length,
|
||||||
|
value: typeof value === "bigint" ? value.toString() : formatNumber(value),
|
||||||
|
...(meaning ? { meaning: meaning(value) } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add("unsigned 8-bit", 1, (cursor) => cursor.readUint8());
|
||||||
|
add("signed 8-bit", 1, (cursor) => cursor.readInt8());
|
||||||
|
add("unsigned 16-bit", 2, (cursor) => cursor.readUint16());
|
||||||
|
add("signed 16-bit", 2, (cursor) => cursor.readInt16());
|
||||||
|
add("unsigned 24-bit", 3, (cursor) => cursor.readUint24());
|
||||||
|
add(
|
||||||
|
"unsigned 32-bit",
|
||||||
|
4,
|
||||||
|
(cursor) => cursor.readUint32(),
|
||||||
|
(value) => unixTimestampMeaning(Number(value)),
|
||||||
|
);
|
||||||
|
add("signed 32-bit", 4, (cursor) => cursor.readInt32());
|
||||||
|
add("unsigned 64-bit", 8, (cursor) => cursor.readBigUint64());
|
||||||
|
add("signed 64-bit", 8, (cursor) => cursor.readBigInt64());
|
||||||
|
add("IEEE-754 float32", 4, (cursor) => cursor.readFloat32());
|
||||||
|
add("IEEE-754 float64", 8, (cursor) => cursor.readFloat64());
|
||||||
|
const textBytes = input.subarray(offset, Math.min(input.length, offset + 32));
|
||||||
|
if (textBytes.length) {
|
||||||
|
results.push({
|
||||||
|
type: "UTF-8 preview",
|
||||||
|
bytes: textBytes.length,
|
||||||
|
value: JSON.stringify(new TextDecoder().decode(textBytes)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBookmarks(
|
||||||
|
bookmarks: readonly ByteBookmark[],
|
||||||
|
byteLength: number,
|
||||||
|
): ByteBookmark[] {
|
||||||
|
if (bookmarks.length > BOOKMARK_LIMIT)
|
||||||
|
throw new RangeError(`At most ${BOOKMARK_LIMIT} bookmarks are allowed.`);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
return bookmarks.map((bookmark) => {
|
||||||
|
if (!bookmark.id.trim() || ids.has(bookmark.id))
|
||||||
|
throw new TypeError("Bookmark IDs must be non-empty and unique.");
|
||||||
|
ids.add(bookmark.id);
|
||||||
|
const name = bookmark.name.trim();
|
||||||
|
if (!name || Array.from(name).length > 100)
|
||||||
|
throw new TypeError("Bookmark names must contain 1–100 characters.");
|
||||||
|
checkedByteRange(
|
||||||
|
byteLength,
|
||||||
|
bookmark.offset,
|
||||||
|
bookmark.length,
|
||||||
|
"Bookmark range",
|
||||||
|
);
|
||||||
|
if (bookmark.color && !/^#[0-9a-fA-F]{6}$/u.test(bookmark.color))
|
||||||
|
throw new TypeError("Bookmark colors must be six-digit hex colors.");
|
||||||
|
return { ...bookmark, name };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBinaryTemplate(
|
||||||
|
input: Uint8Array,
|
||||||
|
template: BinaryTemplate | string,
|
||||||
|
): TemplateResult {
|
||||||
|
const definition =
|
||||||
|
typeof template === "string"
|
||||||
|
? BINARY_TEMPLATES.find((item) => item.id === template)
|
||||||
|
: template;
|
||||||
|
if (!definition) throw new TypeError(`Unknown binary template: ${template}`);
|
||||||
|
validateTemplate(definition);
|
||||||
|
if (input.length < definition.minimumBytes)
|
||||||
|
throw new RangeError(
|
||||||
|
`${definition.name} needs at least ${definition.minimumBytes} bytes.`,
|
||||||
|
);
|
||||||
|
const fields = definition.fields.map((field) =>
|
||||||
|
readTemplateField(input, field),
|
||||||
|
);
|
||||||
|
const expected = fields.filter((field) => field.matchesExpectation !== null);
|
||||||
|
return {
|
||||||
|
templateId: definition.id,
|
||||||
|
name: definition.name,
|
||||||
|
recognized:
|
||||||
|
expected.length > 0 &&
|
||||||
|
expected.every((field) => field.matchesExpectation),
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTemplate(template: BinaryTemplate): void {
|
||||||
|
if (!template.id.trim() || !template.name.trim())
|
||||||
|
throw new TypeError("Template identity is required.");
|
||||||
|
if (!Number.isSafeInteger(template.minimumBytes) || template.minimumBytes < 0)
|
||||||
|
throw new TypeError("Template minimum byte length is invalid.");
|
||||||
|
if (!template.fields.length || template.fields.length > TEMPLATE_FIELD_LIMIT)
|
||||||
|
throw new RangeError(`Templates need 1–${TEMPLATE_FIELD_LIMIT} fields.`);
|
||||||
|
for (const field of template.fields) {
|
||||||
|
if (!field.name.trim())
|
||||||
|
throw new TypeError("Template field names are required.");
|
||||||
|
const length = fieldByteLength(field);
|
||||||
|
checkedOffsetAdd(field.offset, length, MAX_BYTES, "Template field range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTemplateField(
|
||||||
|
input: Uint8Array,
|
||||||
|
field: TemplateFieldDefinition,
|
||||||
|
): TemplateFieldResult {
|
||||||
|
const length = fieldByteLength(field);
|
||||||
|
checkedByteRange(
|
||||||
|
input.length,
|
||||||
|
field.offset,
|
||||||
|
length,
|
||||||
|
`Template field ${field.name}`,
|
||||||
|
);
|
||||||
|
const cursor = new ByteCursor(input, {
|
||||||
|
byteOffset: field.offset,
|
||||||
|
byteLength: length,
|
||||||
|
littleEndian: field.endianness === "little",
|
||||||
|
maximumBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
let value: string;
|
||||||
|
switch (field.type) {
|
||||||
|
case "uint8":
|
||||||
|
value = String(cursor.readUint8());
|
||||||
|
break;
|
||||||
|
case "uint16":
|
||||||
|
value = String(cursor.readUint16());
|
||||||
|
break;
|
||||||
|
case "uint24":
|
||||||
|
value = String(cursor.readUint24());
|
||||||
|
break;
|
||||||
|
case "uint32":
|
||||||
|
value = String(cursor.readUint32());
|
||||||
|
break;
|
||||||
|
case "uint64":
|
||||||
|
value = cursor.readBigUint64().toString();
|
||||||
|
break;
|
||||||
|
case "int8":
|
||||||
|
value = String(cursor.readInt8());
|
||||||
|
break;
|
||||||
|
case "int16":
|
||||||
|
value = String(cursor.readInt16());
|
||||||
|
break;
|
||||||
|
case "int32":
|
||||||
|
value = String(cursor.readInt32());
|
||||||
|
break;
|
||||||
|
case "float32":
|
||||||
|
value = formatNumber(cursor.readFloat32());
|
||||||
|
break;
|
||||||
|
case "float64":
|
||||||
|
value = formatNumber(cursor.readFloat64());
|
||||||
|
break;
|
||||||
|
case "ascii":
|
||||||
|
value = cursor.readAscii(length);
|
||||||
|
break;
|
||||||
|
case "hex":
|
||||||
|
value = [...cursor.readBytes(length)]
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
byteLength: length,
|
||||||
|
value,
|
||||||
|
matchesExpectation:
|
||||||
|
field.expected === undefined ? null : value === field.expected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldByteLength(field: TemplateFieldDefinition): number {
|
||||||
|
if (field.type === "ascii" || field.type === "hex") {
|
||||||
|
if (!Number.isSafeInteger(field.length) || (field.length ?? 0) < 1)
|
||||||
|
throw new TypeError(`${field.name} needs a positive byte length.`);
|
||||||
|
return field.length!;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uint8: 1,
|
||||||
|
int8: 1,
|
||||||
|
uint16: 2,
|
||||||
|
int16: 2,
|
||||||
|
uint24: 3,
|
||||||
|
uint32: 4,
|
||||||
|
int32: 4,
|
||||||
|
float32: 4,
|
||||||
|
uint64: 8,
|
||||||
|
float64: 8,
|
||||||
|
}[field.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
if (Number.isNaN(value)) return "NaN";
|
||||||
|
if (value === Number.POSITIVE_INFINITY) return "+Infinity";
|
||||||
|
if (value === Number.NEGATIVE_INFINITY) return "-Infinity";
|
||||||
|
if (Object.is(value, -0)) return "-0";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixTimestampMeaning(value: number): string | undefined {
|
||||||
|
if (!Number.isSafeInteger(value) || value > 4_102_444_800) return undefined;
|
||||||
|
try {
|
||||||
|
return `Unix seconds: ${new Date(value * 1_000).toISOString()}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAbortError(): Error {
|
||||||
|
return typeof DOMException === "undefined"
|
||||||
|
? Object.assign(new Error("Search cancelled"), { name: "AbortError" })
|
||||||
|
: new DOMException("Search cancelled", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReplacementHex(source: string): Uint8Array {
|
||||||
|
return decodeInput(source, "hex");
|
||||||
|
}
|
||||||
@@ -94,6 +94,45 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--toolbox-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table {
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border: 1px solid var(--toolbox-border);
|
||||||
|
border-radius: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table :where(th, td) {
|
||||||
|
padding: 0.48rem 0.58rem;
|
||||||
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--toolbox-surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table td small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.decoded-output,
|
.decoded-output,
|
||||||
.byte-table {
|
.byte-table {
|
||||||
max-height: 34rem;
|
max-height: 34rem;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.binary-tools",
|
"id": "de.add-ideas.binary-tools",
|
||||||
"name": "Binary Tools",
|
"name": "Binary Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Convert, inspect and decode bytes locally.",
|
"description": "Convert, inspect and decode bytes locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -29,6 +29,40 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
|
"io": {
|
||||||
|
"accepts": [
|
||||||
|
{
|
||||||
|
"mediaType": "*/*",
|
||||||
|
"extensions": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/octet-stream",
|
||||||
|
"extensions": [".bin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"extensions": [".txt", ".hex"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
{
|
||||||
|
"mediaType": "application/octet-stream",
|
||||||
|
"extensions": [".bin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"extensions": [".txt", ".hex"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mediaType": "application/json",
|
||||||
|
"extensions": [".json"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"required": [],
|
||||||
|
"optional": ["workers"]
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -35,3 +35,31 @@ test("keeps the installed application available offline", async ({
|
|||||||
await context.setOffline(false);
|
await context.setOffline(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("edits, interprets and searches a larger buffer in a bounded worker", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.locator('input[type="file"]').evaluate((input) => {
|
||||||
|
const bytes = new Uint8Array(70_000);
|
||||||
|
bytes.fill(0x41);
|
||||||
|
bytes[69_999] = 0x42;
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(
|
||||||
|
new File([bytes], "large.bin", { type: "application/octet-stream" }),
|
||||||
|
);
|
||||||
|
(input as HTMLInputElement).files = transfer.files;
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Pattern", exact: true })
|
||||||
|
.fill("41 41 42");
|
||||||
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("1 match");
|
||||||
|
await page.getByLabel("Replacement bytes (hex)").fill("ff");
|
||||||
|
await page.getByRole("button", { name: "Apply byte edit" }).click();
|
||||||
|
await expect(page.getByLabel("hex output")).toHaveValue(/^ff/u);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Typed interpretations" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/deep/nested/binary/");
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("main .loading, main .workbench-loading"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const widths = await page.evaluate(() => ({
|
||||||
|
content: document.documentElement.scrollWidth,
|
||||||
|
viewport: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||||
|
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||||
|
});
|
||||||
@@ -18,8 +18,23 @@ describe("Workbench", () => {
|
|||||||
it("shows malformed decoder errors without clearing bytes", async () => {
|
it("shows malformed decoder errors without clearing bytes", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<Workbench />);
|
render(<Workbench />);
|
||||||
await user.click(screen.getByRole("tab", { name: "ASN.1 DER" }));
|
await user.click(screen.getByRole("button", { name: "ASN.1 DER" }));
|
||||||
expect(screen.getByRole("alert")).toBeVisible();
|
expect(screen.getByRole("alert")).toBeVisible();
|
||||||
expect(screen.getByRole("button", { name: "Byte 20: 0a" })).toBeVisible();
|
expect(screen.getByRole("button", { name: "Byte 20: 0a" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("edits and searches the canonical byte buffer", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Workbench />);
|
||||||
|
const editOffset = screen.getAllByLabelText("Offset (decimal or 0x…)")[0]!;
|
||||||
|
await user.clear(editOffset);
|
||||||
|
await user.type(editOffset, "0");
|
||||||
|
await user.type(screen.getByLabelText("Replacement bytes (hex)"), "41");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply byte edit" }));
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("hex output") as HTMLTextAreaElement).value,
|
||||||
|
).toMatch(/^41/u);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Search" }));
|
||||||
|
expect(await screen.findByText(/1 match/u)).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyBinaryTemplate,
|
||||||
|
deleteBytes,
|
||||||
|
findBytePattern,
|
||||||
|
insertBytes,
|
||||||
|
interpretBytes,
|
||||||
|
parseSearchPattern,
|
||||||
|
replaceByteRange,
|
||||||
|
validateBookmarks,
|
||||||
|
} from "../../src/core/workbench";
|
||||||
|
|
||||||
|
describe("byte workbench primitives", () => {
|
||||||
|
it("edits checked ranges without mutating the source", () => {
|
||||||
|
const source = Uint8Array.from([1, 2, 3, 4]);
|
||||||
|
expect([
|
||||||
|
...replaceByteRange(source, 1, 2, Uint8Array.from([9, 8])),
|
||||||
|
]).toEqual([1, 9, 8, 4]);
|
||||||
|
expect([...insertBytes(source, 2, Uint8Array.from([7]))]).toEqual([
|
||||||
|
1, 2, 7, 3, 4,
|
||||||
|
]);
|
||||||
|
expect([...deleteBytes(source, 1, 2)]).toEqual([1, 4]);
|
||||||
|
expect([...source]).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(() => deleteBytes(source, 3, 2)).toThrow(/Edit range/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches exact and wildcard byte patterns with a bounded result set", () => {
|
||||||
|
const bytes = new TextEncoder().encode("hello hallo hello");
|
||||||
|
const pattern = parseSearchPattern("68 ?? 6c 6c 6f", "hex");
|
||||||
|
expect(findBytePattern(bytes, pattern).offsets).toEqual([0, 6, 12]);
|
||||||
|
expect(
|
||||||
|
findBytePattern(bytes, parseSearchPattern("hello", "utf8"), {
|
||||||
|
maximumMatches: 1,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ offsets: [0], truncated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interprets typed values with explicit byte order", () => {
|
||||||
|
const bytes = Uint8Array.from([1, 2, 3, 4, 0, 0, 0, 0]);
|
||||||
|
const little = interpretBytes(bytes, 0, "little");
|
||||||
|
const big = interpretBytes(bytes, 0, "big");
|
||||||
|
expect(little.find((item) => item.type === "unsigned 32-bit")?.value).toBe(
|
||||||
|
"67305985",
|
||||||
|
);
|
||||||
|
expect(big.find((item) => item.type === "unsigned 32-bit")?.value).toBe(
|
||||||
|
"16909060",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies built-in templates and validates bookmark ranges", () => {
|
||||||
|
const png = Uint8Array.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48,
|
||||||
|
0x44, 0x52, 0, 0, 0, 2, 0, 0, 0, 3,
|
||||||
|
]);
|
||||||
|
const result = applyBinaryTemplate(png, "png-header");
|
||||||
|
expect(result.recognized).toBe(true);
|
||||||
|
expect(result.fields.find((field) => field.name === "width")?.value).toBe(
|
||||||
|
"2",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateBookmarks(
|
||||||
|
[{ id: "ihdr", name: "IHDR", offset: 8, length: 16 }],
|
||||||
|
png.length,
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(() =>
|
||||||
|
validateBookmarks(
|
||||||
|
[{ id: "bad", name: "Bad", offset: 23, length: 2 }],
|
||||||
|
png.length,
|
||||||
|
),
|
||||||
|
).toThrow(/Bookmark range/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user