发布 0.2.1 并完善工具边界与安全性
This commit is contained in:
@@ -15,8 +15,10 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Web Tools is an anonymous-access, self-hosted Vue 3 + TypeScript browser-local tools application with a zero-dependency Node.js static/configuration server.
|
||||
- The default production port is `9000`. Port `8081` is only the local Vite development proxy target.
|
||||
- The application is anonymous. Do not add login, accounts, favorites, sponsorship, analytics, telemetry, advertising, CDN assets, or a database.
|
||||
- A deployment may place Cloudflare in front of the origin. The server's per-response CSP nonce exists only to support Cloudflare JavaScript Detections without `unsafe-inline`; it is not permission to add application analytics, telemetry, or remote tool processing.
|
||||
- Tool inputs, keys, files, images, and results must remain inside the current browser tab. Do not send them to the Node server, a third-party API, or any remote service.
|
||||
- `/setting` is a fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order.
|
||||
- `/tools/setting` is the fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order.
|
||||
- The first-run default category order is `encoding`, `checksum`, `crypto`, `formatting`, `image`; an existing saved navigation file keeps its custom order and is never reset by a release.
|
||||
- The server persists only navigation configuration. Preserve `data/navigation.json` and the configured external data directory; writes must remain temporary-file plus atomic-replace operations.
|
||||
- Multiple users and tabs may use every tool concurrently. Their calculation state must remain independent; only navigation order is globally shared.
|
||||
|
||||
@@ -34,14 +36,14 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
|
||||
## Stable tool behavior
|
||||
|
||||
- ASCII/Hex uses UTF-8. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes.
|
||||
- UTF-8 / Hex has the permanent tool ID `utf8-hex` and route `/tools/utf8-hex`. It uses strict UTF-8 conversion. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes.
|
||||
- Base64 supports standard and URL-safe input. Decoded output supports strict UTF-8, continuous Hex, and `0xNN, ...` byte arrays. Reject invalid characters, padding placement, lengths, and non-canonical trailing bits.
|
||||
- AES-128-CMAC follows RFC 4493. The key is exactly 16 bytes of Hex and accepts continuous input or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. Hex messages use the same parser.
|
||||
- CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. Combined data is ordered by address without filling gaps, and per-block results remain available.
|
||||
- AES-CMAC accepts exactly 16, 24, or 32 key bytes for AES-128, AES-192, or AES-256, defaulting to 16 bytes. Never pad or truncate the key. Key and Hex-message input accepts continuous Hex or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. The CMAC tag remains exactly 16 bytes for every key size. Preserve RFC 4493 AES-128 and NIST SP 800-38B AES-192/256 vectors.
|
||||
- CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. The Chinese UI labels `RefIn`/`RefOut` as input/output bit reversal; these flags do not reverse byte order or the complete input. Combined data is ordered by address without filling gaps, and per-block results remain available.
|
||||
- Firmware Hash accepts Intel HEX and computes SHA-256 and SHA-512. Effective-data mode concatenates sorted blocks without gap filling. Block-header mode prepends exactly two binary uint32 values, `startAddress` and data-only `dataLength`, to every block. Both fields are four bytes; Big Endian is the default; total input is `Header1 || Data1 || Header2 || Data2 ...`. Never hash a textual Hex representation of the header.
|
||||
- RSA allows 2048, 3072, and 4096-bit keys with public exponent 65537. Prefer Web Crypto; the node-forge fallback must be seeded from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays.
|
||||
- Secure random output comes only from `crypto.getRandomValues`, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes.
|
||||
- JSON/XML formatting, minification, validation, and syntax highlighting stay local. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it.
|
||||
- RSA allows 2048, 3072, and 4096-bit keys with public exponent 65537. Prefer Web Crypto; dynamically load the node-forge fallback only when needed, and seed it from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays.
|
||||
- Secure random output comes only from `crypto.getRandomValues`, defaults to 16 bytes, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes. Missing or failed cryptographic sources must fail closed without a pseudorandom fallback.
|
||||
- JSON/XML formatting, minification, validation, and syntax highlighting stay local. XML parser failures are identified by the browser parser-error namespace; a valid user element named `parsererror` must not be rejected. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it.
|
||||
- PNG/ICO processing stays local and must enforce both compressed-file and decoded-pixel limits before costly rendering or allocation.
|
||||
|
||||
## Intel HEX and asynchronous safety
|
||||
@@ -49,6 +51,8 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Treat every address, length, end-address, offset, and allocation calculation as checked uint32 arithmetic. JavaScript bitwise operators are signed 32-bit and must not be used as unchecked address arithmetic.
|
||||
- Validate record syntax, byte counts, checksums, control-record lengths/addresses, EOF placement, extended addresses, and the `0xFFFFFFFF` boundary.
|
||||
- Sort records by absolute address, merge contiguous records, deduplicate identical overlaps, and reject conflicting overlaps. Enforce record, block, and effective-byte limits before allocating combined buffers.
|
||||
- The default Intel HEX effective-data allocation limit is 64 MiB (`DEFAULT_MAX_INTEL_HEX_BYTES`); keep it aligned with `MAX_FIRMWARE_FILE_SIZE` and the limits table in `README.zh-CN.md`.
|
||||
- Navigation GET normalization and PUT writes are serialized within each Node process. Do not remove that lock without replacing it with an equally strong concurrency design.
|
||||
- CRC and Firmware Hash may share the reviewed Intel HEX parser but must keep separate calculation logic, Worker protocols, and Vue state.
|
||||
- File replacement, clear, mode change, option change, repeated generation, and component unmount can invalidate asynchronous work. Terminate obsolete Workers where possible and use monotonically increasing request/version IDs. A stale Worker, file read, image decode, or key-generation result must never replace current state.
|
||||
- Clear the native file input value after handling a selection so choosing the same file again still emits a change event.
|
||||
@@ -61,15 +65,16 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Keep desktop controls compact. Use stable grid/flex constraints and allow controls to become full-width where appropriate on narrow screens.
|
||||
- Prevent horizontal page overflow, clipped controls, nested scrolling accidents, text overlap, layout shifts, and unbounded result columns. Long Hex, hashes, filenames, and table cells require explicit wrapping or contained scrolling.
|
||||
- Version information remains at the bottom of the sidebar and is read from `package.json`. Every page footer displays exactly `Copyright © mid All Rights Reserved.`
|
||||
- Do not add a visible link to `/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI.
|
||||
- Do not add a visible link to `/tools/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI.
|
||||
- Every native `input`, `select`, and `textarea` must have a unique `id` and stable `name`. `TextEditor` requires an explicit `id` and uses it as the default `name`; preserve the existing implicit or explicit label association.
|
||||
- Preserve the application icon contract under `public/`: `favicon.svg`, a 16/32/48-frame `favicon.ico`, `apple-touch-icon.png` at 180x180, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, and `site.webmanifest`. Keep the icon visually aligned with the sidebar brand and keep the maskable artwork inside its safe zone.
|
||||
- The manifest is metadata/install-ready only. Do not add a Service Worker, offline cache, install prompt, splash-screen bundle, Safari mask icon, legacy `browserconfig.xml`, redundant Apple sizes, or separate 16/32 PNG favicons unless requirements explicitly change.
|
||||
- Preserve the share metadata contract: the single user-facing description is exactly `一个匿名访问、自托管的浏览器本地工具箱`, represented by the standard HTML description and the identical `og:description` compatibility field. Do not add a custom share image, other Open Graph or platform-specific metadata, platform crawlers, route-specific previews, tool lists, or user data. Sharing clients may fall back to the document title and existing favicon assets, but their final preview and cache behavior remain platform-controlled.
|
||||
- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/setting`, both themes, console errors, and network requests.
|
||||
- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/tools/setting`, both themes, console errors, and network requests.
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Choose a permanent, unique kebab-case ID. Published IDs must not change because routes and saved navigation depend on them.
|
||||
1. Choose a permanent, unique kebab-case ID. IDs must remain stable after publication because routes and saved navigation depend on them. The recorded pre-0.2.0 correction from `ascii-hex` to `utf8-hex` is the sole existing ID migration. `setting` is reserved by `/tools/setting` and cannot be used as a tool ID.
|
||||
2. Add pure logic and normal, invalid, boundary, resource-limit, and known-vector tests.
|
||||
3. Add one Vue component under `src/tools/` using shared components and responsive styles.
|
||||
4. Add matching metadata to `shared/tool-catalog.json` and its default position to `shared/default-navigation.json`. Only change navigation `version: 1` for a schema migration, not for a new tool.
|
||||
@@ -77,6 +82,7 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
6. Verify that an old saved `navigation.json` retains its order and receives the missing tool after the Node process restarts and navigation is next loaded.
|
||||
|
||||
IDs must remain synchronized across the catalog, default navigation, icon map, and component registry. Existing catalog tests enforce this contract.
|
||||
The `ascii-hex` ID is a legacy input accepted only while normalizing existing `navigation.json` files; it migrates in place to `utf8-hex`. Do not restore it as a catalog ID, component key, form-field prefix, or route.
|
||||
|
||||
## Deployment and release
|
||||
|
||||
@@ -85,6 +91,9 @@ IDs must remain synchronized across the catalog, default navigation, icon map, a
|
||||
- The Windows update flow requires a clean `main` checkout and uses `git pull --ff-only`. Preserve external `C:\ProgramData\WebTools\data` state.
|
||||
- Running `web-tools.ps1` without an action is an interactive user entry point and must keep the start/update/update-without-start/exit menu. Scheduled tasks and automation must pass an explicit `start` or `update` action so they never wait for input.
|
||||
- New catalog data is loaded by the Node process at startup. Rebuilding `dist` alone is insufficient after catalog changes; restart the server and refresh already-open browser pages.
|
||||
- Preserve the per-response 128-bit CSP nonce generated with `node:crypto`. Cloudflare parses the nonce from the response header for JavaScript Detections. Never replace it with a fixed nonce or `unsafe-inline`; keep HTML uncached/no-cache so a document and its nonce cannot become mismatched.
|
||||
- Keep one-year immutable caching limited to fingerprinted files under `dist/assets/`. Stable filenames such as HTML, favicon assets, and `site.webmanifest` must use `no-cache` so a deployment can replace them safely.
|
||||
- Browser navigation PUT requests carrying Fetch Metadata must be same-origin. Requests without `Sec-Fetch-Site` remain supported for non-browser administration, so document this as defense in depth and never as authentication.
|
||||
- If a single EXE is later authorized, use Node.js SEA with embedded read-only web/catalog assets and an external writable data directory. Add per-architecture builds, asset-read abstraction, Windows smoke tests, replacement/update instructions, and optional Authenticode signing. Derive EXE version metadata from `package.json`; do not create another manually maintained version source. Do not commit generated executables.
|
||||
|
||||
For a release, synchronize these five tracked files:
|
||||
|
||||
@@ -2,6 +2,53 @@
|
||||
|
||||
本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。
|
||||
|
||||
## 0.2.1 - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Base64 解码现在拒绝 `TQ=`、`TWE==` 等长度不完整的显式填充,同时继续接受规范的无填充 Base64 URL 输入。
|
||||
- XML 校验改为识别浏览器专用解析错误命名空间,合法的 `<parsererror>` 用户元素不再被误判为解析失败。
|
||||
- favicon、Web App Manifest 和其他稳定文件名资源不再使用一年不可变缓存;只有带构建指纹的 `dist/assets/` 文件使用长期 `immutable` 缓存。
|
||||
|
||||
### Security
|
||||
|
||||
- 带 Fetch Metadata 的导航配置写入现在只接受 `Sec-Fetch-Site: same-origin`,同时拦截 `same-site` 与 `cross-site` 浏览器跨源请求。无该请求头的非浏览器客户端仍受支持,因此隐藏设置路径仍不构成身份认证。
|
||||
|
||||
### Verification
|
||||
|
||||
- 120 条 Vitest 测试通过,新增 Base64 显式填充、XML `parsererror` 合法元素、静态资源缓存分类和 same-site 跨源写入回归覆盖。
|
||||
- Vue/TypeScript 类型检查、Vite 生产构建、Git 差异规范检查和 Windows PowerShell 启动脚本语法检查通过。
|
||||
- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器回归;深浅主题无横向溢出或控制台警告/错误。
|
||||
|
||||
## 0.2.0 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- AES-CMAC 增加 AES-192 和 AES-256 密钥长度,保留 AES-128 默认值,并增加 NIST SP 800-38B 已知向量。
|
||||
- 服务端为每个响应生成独立的 128 位 CSP nonce,使 Cloudflare JavaScript Detections 无需 `'unsafe-inline'` 便可与严格 CSP 兼容。
|
||||
|
||||
### Changed
|
||||
|
||||
- 安全随机数默认长度从 8 字节调整为 16 字节,并集中管理默认值和资源上下限。
|
||||
- CRC 自定义参数和结果表头改为中文在前、英文在后的双语标签;`RefIn`/`RefOut` 明确为输入/输出位反转,宽屏参数区不再压缩长标签换行。
|
||||
- CRC 与固件 Hash 的文件上传框统一悬浮、拖拽及键盘聚焦状态,并增加与后续控件的固定间距。
|
||||
- RSA 仅在 Web Crypto 不可用或失败时动态加载 `node-forge` 回退,降低现代 HTTPS 浏览器首次打开 RSA 工具时的下载量。
|
||||
- UTF-8 / Hex 的工具 ID、组件、字段前缀和固定路径统一为 `utf8-hex` 与 `/tools/utf8-hex`;旧导航配置中的 `ascii-hex` 会原位迁移,保留分类和排序。
|
||||
- 侧边栏移除无功能编号,品牌副标题改为“本地工具箱”,共享控件统一键盘焦点环和禁用态。
|
||||
- 工具管理路径调整为 `/tools/setting`;所有表单字段补充唯一 `id` 和稳定 `name`。
|
||||
|
||||
### Security
|
||||
|
||||
- 随机数增加最小/最大长度、安全随机源缺失及随机源异常的失败关闭测试,不使用普通伪随机回退。
|
||||
- CSP 仍禁止任意内联脚本和第三方脚本;Cloudflare Web Analytics 保持为部署层可选功能,不加入应用代码。
|
||||
- RSA 密钥生成增加异步请求版本保护,组件卸载或状态失效后,旧结果不会覆盖当前页面状态。
|
||||
|
||||
### Verification
|
||||
|
||||
- 116 条 Vitest 测试通过,覆盖 AES-128/192/256 标准向量、安全随机源失败关闭、RSA 动态回退、CSP nonce 强度及唯一性、固定工具路径、导航 ID 迁移、默认分类顺序、Intel HEX 有效数据分配上限、并发导航写入、CRC 参数防御和表单字段标识约束。
|
||||
- Vue/TypeScript 类型检查、Vite 生产构建和 Windows PowerShell 启动脚本语法检查通过。
|
||||
- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器检查;深浅主题无横向溢出或控制台警告/错误。
|
||||
|
||||
## 0.1.0 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,13 +8,13 @@ The authoritative application version is the `version` field in `package.json`.
|
||||
|
||||
## Included tools
|
||||
|
||||
- ASCII/UTF-8 and Hex conversion
|
||||
- UTF-8 and Hex conversion at the canonical route `/tools/utf8-hex`
|
||||
- Base64 and Base64 URL encoding/decoding, with UTF-8, continuous Hex, and source-code byte-array output
|
||||
- AES-128-CMAC (RFC 4493)
|
||||
- AES-CMAC with selectable AES-128, AES-192, and AES-256 keys
|
||||
- CRC-8/16/32/64 presets and custom parameters for BIN and multi-block Intel HEX files
|
||||
- Firmware SHA-256/SHA-512 over Intel HEX effective data, optionally with an address/length header for each block
|
||||
- RSA 2048/3072/4096-bit key-pair generation with PEM, N/E Hex, and byte-array output
|
||||
- Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form
|
||||
- Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form, defaulting to 16 bytes
|
||||
- JSON formatting, minification, validation, and syntax highlighting
|
||||
- XML formatting, minification, validation, and syntax highlighting
|
||||
- PNG to multi-size ICO conversion
|
||||
@@ -22,13 +22,21 @@ The authoritative application version is the `version` field in `package.json`.
|
||||
## Security and state model
|
||||
|
||||
- Tool data is processed locally in browser memory and is not sent to the server or a third party.
|
||||
- There is no login, account, favorites, sponsorship, telemetry, CDN, or database.
|
||||
- `/setting` is a fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order.
|
||||
- There is no login, account, favorites, sponsorship, telemetry, application-loaded CDN asset, or database.
|
||||
- The Node server emits a fresh 128-bit CSP nonce on every response. Cloudflare can attach it to JavaScript Detections injected by its proxy without enabling `unsafe-inline`; this compatibility does not add application analytics or send tool inputs away from the browser.
|
||||
- `/tools/setting` is the fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order.
|
||||
- Existing navigation files that still contain the legacy `ascii-hex` tool ID are migrated in place to `utf8-hex`, preserving their category and position. New configurations and application routes use only `utf8-hex`.
|
||||
- The first-run default category order is Encoding, Checksum, Cryptography, Formatting, and Image. A saved `navigation.json` intentionally keeps its existing custom order; it is not silently reset by an application update.
|
||||
- Theme selection is stored in same-origin browser `localStorage`. The only server-side application data is `navigation.json`.
|
||||
- Browser navigation writes carrying Fetch Metadata are accepted only when `Sec-Fetch-Site` is `same-origin`. Clients that omit the header are still accepted, so this is defense in depth rather than authentication.
|
||||
- The application is suitable for a private network, VPN, or a deployment protected by an external access-control layer. Do not treat the hidden settings URL as an Internet-facing security boundary.
|
||||
|
||||
See [README.zh-CN.md](README.zh-CN.md) for the exact algorithm contracts, input limits, concurrency behavior, and server security controls.
|
||||
|
||||
## Resource limits
|
||||
|
||||
The browser rejects oversized or structurally dense inputs before costly allocation. The current limits are 64 MiB for firmware files and merged Intel HEX effective data, 500,000 non-empty Intel HEX records, 1,024 Intel HEX blocks, 32 MiB for compressed PNG files, 16,384 pixels per PNG side and 32,000,000 decoded pixels, 1-65,536 bytes for secure random output (default 16), and 100,000 characters/10,000 tokens for syntax highlighting. Highlighting falls back to complete plain text; it never truncates a result.
|
||||
|
||||
## Docker deployment
|
||||
|
||||
Requirements: Docker Engine 24+ and Docker Compose v2 on Linux AMD64 or ARM64.
|
||||
@@ -117,6 +125,10 @@ public/ browser icons and Web App Manifest
|
||||
|
||||
New tools must keep their computation independent, register the same stable ID in the catalog, default navigation, and lazy-load registry, reuse the shared UI, define resource limits where appropriate, and include normal, invalid, boundary, and known-vector tests. Routes are generated from the tool registry; ordinary tools do not add routes manually.
|
||||
|
||||
RSA uses Web Crypto first and dynamically loads the bundled `node-forge` fallback chunk from the same origin only when the native provider is unavailable or fails. Tool components remain lazy-loaded; the project does not add a Service Worker or duplicate the Brotli/Gzip compression normally supplied by a production reverse proxy such as Cloudflare.
|
||||
|
||||
Only fingerprinted files under `dist/assets/` receive long-lived immutable caching. HTML, icons, the Manifest, and other stable filenames use `no-cache` so deployments can update them without waiting for a one-year browser cache to expire. XML validation identifies browser parser failures by their dedicated namespace, so a valid user element named `parsererror` is not rejected.
|
||||
|
||||
The browser icon set follows the sidebar brand and includes SVG, multi-frame ICO, Apple touch, 192/512, and maskable assets. The manifest supplies metadata only: the project deliberately has no Service Worker, offline cache, install prompt, or splash-screen bundle, so a deployment update cannot be hidden by application-managed stale caches.
|
||||
|
||||
The page exposes one product description through both the standard HTML description and an identical `og:description` compatibility field. It has no custom share image, other Open Graph metadata, platform crawler, or platform-specific fields. Sharing clients may fall back to the document title and existing favicons, but each platform controls its final preview and cache refresh behavior.
|
||||
|
||||
+35
-23
@@ -4,21 +4,23 @@
|
||||
|
||||
一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。
|
||||
|
||||
当前应用版本:`0.1.0`。
|
||||
当前应用版本:`0.2.1`。
|
||||
|
||||
## 内置工具
|
||||
|
||||
- ASCII/UTF-8 与 Hex 互转
|
||||
- UTF-8 与 Hex 互转,固定路径为 `/tools/utf8-hex`
|
||||
- Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组
|
||||
- AES-128-CMAC(RFC 4493)
|
||||
- AES-CMAC,支持 AES-128、AES-192 和 AES-256 密钥
|
||||
- CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX
|
||||
- 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头
|
||||
- RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组
|
||||
- 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组
|
||||
- 密码学安全随机数,默认 16 字节,支持连续 Hex、空格 Hex 和代码字节数组
|
||||
- JSON 格式化、压缩、校验和语法着色
|
||||
- XML 格式化、压缩、校验和语法着色
|
||||
- PNG 转多尺寸 ICO
|
||||
|
||||
首次部署且尚未保存过自定义配置时,默认分类顺序为“编码转换 → 校验计算 → 加密计算 → 格式化 → 图像处理”。已有 `data/navigation.json` 会保留用户排序,不会因升级被默认顺序覆盖。
|
||||
|
||||
## 交付物与源码
|
||||
|
||||
容器交付 ZIP 同时包含源码、测试、生产构建结果和部署文件,不是只有压缩后的网页。主要目录如下:
|
||||
@@ -29,7 +31,7 @@ src/ Vue 3 前端源码
|
||||
lib/ 可测试的纯计算与浏览器辅助逻辑
|
||||
tools/ 每个工具一个独立 Vue 组件
|
||||
workers/ 大文件计算使用的浏览器后台线程
|
||||
views/SettingView.vue /setting 工具管理页
|
||||
views/SettingView.vue /tools/setting 工具管理页
|
||||
server/ 零依赖 Node.js 静态服务和导航配置 API
|
||||
shared/tool-catalog.json 工具与分类的权威元数据目录
|
||||
shared/default-navigation.json 新部署的默认分类和工具顺序
|
||||
@@ -53,7 +55,7 @@ CHANGELOG.md 版本变更、修复和验证记录
|
||||
- 路由和左侧导航由工具目录自动生成,增加普通工具时不得手工新增路由。
|
||||
- 工具输入只在浏览器内存中处理,不得发送给服务端、遥测服务或第三方接口。
|
||||
- 服务端只持久化导航顺序,文件为 `/data/navigation.json`,写入采用临时文件和原子替换。
|
||||
- `/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。
|
||||
- `/tools/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。
|
||||
- 页面必须保持统一 UI,并兼容桌面端和移动端竖屏。
|
||||
- 浏览器图标与左侧品牌标记保持一致。`favicon.svg` 是现代浏览器主图标,`favicon.ico` 内含 16/32/48 像素回退图,另提供 180 像素 Apple 图标、192/512 像素 Web App 图标和 512 像素 maskable 图标。
|
||||
|
||||
@@ -65,21 +67,22 @@ CHANGELOG.md 版本变更、修复和验证记录
|
||||
|
||||
以下行为属于已经发布的兼容性约束。修改时必须同步实现、页面提示、自动化测试和本文档,不能只调整界面文案。
|
||||
|
||||
- ASCII/Hex 工具按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。
|
||||
- UTF-8 / Hex 工具的固定 ID 为 `utf8-hex`,路径为 `/tools/utf8-hex`。它按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。
|
||||
- Base64 支持标准和 URL-safe 形式;解码结果可显示为严格 UTF-8、连续 Hex 或 `0x48, 0x9F, ...` 字节数组。无效字符、错误填充和非规范编码必须报错。
|
||||
- AES-128-CMAC 密钥必须恰好为 16 字节。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀;计算语义遵循 RFC 4493。
|
||||
- CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。
|
||||
- AES-CMAC 密钥长度可选 128、192 或 256 位,默认 128 位,并分别严格要求 16、24 或 32 字节;不自动补零或截断。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀。CMAC 标签始终为 128 位;AES-128 向量遵循 RFC 4493,AES-192/256 向量遵循 NIST SP 800-38B。
|
||||
- CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。`RefIn`/`RefOut` 在页面中标为输入/输出位反转,不表示字节顺序或整个数据倒序。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。
|
||||
- 固件 Hash 只接受 Intel HEX,输出 SHA-256 和 SHA-512。“有效数据”与“Block 校验头”模式的确切字节流见“固件 Hash 固定语义”;默认校验头字节序为 Big Endian。
|
||||
- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,必要时使用由 `crypto.getRandomValues` 提供随机种子的 `node-forge` 回退;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。
|
||||
- 安全随机数必须来自浏览器 `crypto.getRandomValues`,长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。
|
||||
- JSON/XML 的格式化、压缩和校验均在浏览器本地完成。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。
|
||||
- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,只有本地提供者不可用或失败时才动态加载 `node-forge` 回退,其随机种子仍只来自 `crypto.getRandomValues`;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。
|
||||
- 安全随机数必须来自浏览器 `crypto.getRandomValues`,默认 16 字节,允许长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。安全随机源不可用或抛错时直接失败,不使用普通伪随机回退。
|
||||
- JSON/XML 的格式化、压缩和校验均在浏览器本地完成。合法 XML 可以使用 `parsererror` 作为元素名;只把浏览器专用解析错误命名空间中的节点视为解析失败。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。
|
||||
- 文件选择、文件更换、清空、模式切换和组件卸载都可能使异步任务失效。旧 Worker、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。
|
||||
|
||||
## 多人使用与状态边界
|
||||
|
||||
- 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。
|
||||
- CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。
|
||||
- `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。
|
||||
- `/tools/setting` 保存的是所有访问者共享的导航配置。同一服务进程会按进入导航操作队列的顺序串行处理读写,最后排队的保存生效;不提供版本冲突合并。
|
||||
- 旧导航配置中的历史工具 ID `ascii-hex` 会在读取时原位迁移为 `utf8-hex`,保留原分类和排序;新配置和应用路由只使用 `utf8-hex`。
|
||||
- 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。
|
||||
- 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。
|
||||
- 深浅主题保存在当前浏览器同源 `localStorage` 中;系统剪贴板由操作系统管理。这两项不会改变工具计算结果。
|
||||
@@ -93,10 +96,11 @@ CHANGELOG.md 版本变更、修复和验证记录
|
||||
| CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 |
|
||||
| Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 |
|
||||
| Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 |
|
||||
| Intel HEX 合并后的有效数据 | 最大 64 MiB | 合并分配前拒绝,Worker 停止解析并显示错误 |
|
||||
| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 |
|
||||
| PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 |
|
||||
| PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 |
|
||||
| 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 |
|
||||
| 安全随机数 | 默认 16 字节,每次 1 至 65,536 字节 | 拒绝无效长度或不安全回退 |
|
||||
| JSON/XML 语法着色 | 最大 100,000 字符且不超过 10,000 个 token | 结果仍完整显示,但降级为纯文本以减少 DOM 节点 |
|
||||
| 导航配置请求体 | 最大 64 KiB | 服务端返回 400 |
|
||||
|
||||
@@ -139,6 +143,12 @@ docker compose down
|
||||
|
||||
健康检查地址:`http://服务器地址:9000/healthz`
|
||||
|
||||
### Cloudflare 反向代理与 CSP
|
||||
|
||||
服务端在每个响应的 `Content-Security-Policy` 中放入独立的 128 位密码学随机 nonce。Cloudflare JavaScript Detections 会从源站 CSP 响应头读取该 nonce,并把它加到代理注入的脚本上,因此 Bot Fight Mode 可在不启用 `'unsafe-inline'` 的情况下与严格 CSP 共存。不得把 nonce 改为固定值,也不得为消除控制台提示而放开 `'unsafe-inline'`。
|
||||
|
||||
项目自身不添加 Web Analytics、遥测或第三方脚本。Cloudflare Web Analytics 是部署层可选的浏览器 RUM,不是 CDN、WAF、DDoS 或 Bot 防护的必要条件;默认保持关闭时,Cloudflare 边缘流量统计仍然可用。若未来明确启用 Web Analytics,必须单独评审 CSP 外部脚本来源和“无统计/无遥测”文档契约。
|
||||
|
||||
## Windows Server 部署(不使用 Docker)
|
||||
|
||||
要求安装 Git、Node.js 22.12.0 或更高版本,并使用 PowerShell 5.1 或更高版本。以下示例假设项目位于 `C:\Services\WebTools`。
|
||||
@@ -206,7 +216,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update
|
||||
2. 抽象静态资源读取方式,使 Node 服务既能读取开发时的文件系统,也能读取 EXE 内嵌资源。
|
||||
3. 增加 SEA 配置、注入工具(通常为 `postject`)、`build:exe` 脚本以及 Windows x64/ARM64 分架构构建。
|
||||
4. 继续把 `navigation.json` 放在 EXE 外部的可写数据目录,默认使用 `C:\ProgramData\WebTools\data`,升级 EXE 时不得覆盖它。
|
||||
5. 增加 Windows Server 启动、端口、静态资源、`/setting`、更新替换和杀毒软件兼容性冒烟测试。
|
||||
5. 增加 Windows Server 启动、端口、静态资源、`/tools/setting`、更新替换和杀毒软件兼容性冒烟测试。
|
||||
6. 正式对外分发时补充 EXE 图标/版本资源,并考虑 Authenticode 代码签名;未签名程序可能触发 SmartScreen 警告。
|
||||
|
||||
这项改造对源码体积影响不大,通常只是数十 KB 级的入口、资源适配和构建脚本,并增加少量开发依赖;真正明显的是发布产物。每个架构的 EXE 会包含 Node.js 运行时,常见为约 60-100 MB 未压缩、约 30-60 MB 压缩,具体随 Node 版本和资源大小变化。EXE 和中间产物必须保持在 `.gitignore` 或发布目录中,不提交到 Git,因此仓库不会明显膨胀;增加的主要成本是分架构构建、签名、测试和更新维护。
|
||||
@@ -223,7 +233,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update
|
||||
管理入口不显示在页面中,直接访问:
|
||||
|
||||
```text
|
||||
http://服务器地址:9000/setting
|
||||
http://服务器地址:9000/tools/setting
|
||||
```
|
||||
|
||||
可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。
|
||||
@@ -245,10 +255,10 @@ docker compose restart web-tools
|
||||
|
||||
## 离线镜像
|
||||
|
||||
若交付目录包含 `personal-web-tools-0.1.0-amd64.tar`,可直接导入:
|
||||
若交付目录包含 `personal-web-tools-0.2.1-amd64.tar`,可直接导入:
|
||||
|
||||
```bash
|
||||
docker load -i personal-web-tools-0.1.0-amd64.tar
|
||||
docker load -i personal-web-tools-0.2.1-amd64.tar
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
@@ -300,7 +310,7 @@ npm run check
|
||||
npm audit --audit-level=low
|
||||
```
|
||||
|
||||
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。
|
||||
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404 与缓存策略、导航 API、请求大小限制及跨源浏览器写入拦截。
|
||||
|
||||
自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。
|
||||
|
||||
@@ -392,7 +402,7 @@ npm audit --audit-level=low
|
||||
还应在真实浏览器中检查:
|
||||
|
||||
- 新工具的默认路径 `/tools/<id>` 可以直接打开和刷新。
|
||||
- 左侧导航、搜索和 `/setting` 正常显示新工具。
|
||||
- 左侧导航、搜索和 `/tools/setting` 正常显示新工具。
|
||||
- 已保存的旧 `navigation.json` 在服务重启并首次读取后会自动补入新工具;仅重新构建 `dist` 不会刷新已经运行的 Node 进程。
|
||||
- 桌面端和约 `390 x 844` 的移动端竖屏布局正常。
|
||||
- 浏览器控制台无错误,工具内容没有发往服务端。
|
||||
@@ -438,12 +448,14 @@ AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 无登录、无统计、无遥测、无 CDN 和第三方网络请求。
|
||||
- `/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。
|
||||
- 应用代码无登录、无统计、无遥测、无 CDN 资产和第三方网络请求;部署时可在外层使用 Cloudflare 反向代理。
|
||||
- `/tools/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。
|
||||
- 工具内容仅在浏览器内存中处理,默认不会持久化。
|
||||
- 容器以非 root 用户运行,根文件系统只读,全部 Linux capabilities 已移除。
|
||||
- 服务端只接受不超过 64 KiB 的导航 JSON 配置。
|
||||
- 服务端只提供静态文件、`GET/PUT /api/navigation` 和 `GET /healthz`。
|
||||
- 只有无扩展名且明确请求 HTML 的前端路由会回退到 `index.html`;缺失的 JS、CSS 和其他资源返回 404。
|
||||
- 浏览器发起的导航配置写入只接受 `Sec-Fetch-Site: same-origin`;缺少该请求头的非浏览器客户端仍可写入,因此这项检查不能替代身份认证或外层访问控制。
|
||||
- JSON API 响应使用 `Cache-Control: no-store`,避免浏览器缓存导航和健康状态。
|
||||
- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限。
|
||||
- 只有文件名带构建指纹的 `dist/assets/` 资源使用一年 `immutable` 缓存;HTML、favicon、Manifest 和其他稳定文件名资源使用 `no-cache`,更新后会重新验证。
|
||||
- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限;每个响应的 CSP 使用独立 128 位 nonce 兼容 Cloudflare JavaScript Detections,不允许 `'unsafe-inline'`。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
web-tools:
|
||||
image: personal-web-tools:0.1.0
|
||||
image: personal-web-tools:0.2.1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.29.0",
|
||||
"@stablelib/aes": "^2.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
+28
-7
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
@@ -17,6 +18,7 @@ const serverDir = path.dirname(serverFile)
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
export const MAX_JSON_BODY_SIZE = 64 * 1024
|
||||
export const DEFAULT_PORT = 9000
|
||||
export const CSP_NONCE_BYTES = 16
|
||||
|
||||
const mimeTypes = new Map([
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
@@ -32,8 +34,13 @@ const mimeTypes = new Map([
|
||||
['.woff2', 'font/woff2'],
|
||||
])
|
||||
|
||||
export function createContentSecurityPolicy(nonce) {
|
||||
return `default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self' 'nonce-${nonce}'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`
|
||||
}
|
||||
|
||||
function applySecurityHeaders(response) {
|
||||
response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
const nonce = randomBytes(CSP_NONCE_BYTES).toString('base64')
|
||||
response.setHeader('Content-Security-Policy', createContentSecurityPolicy(nonce))
|
||||
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
response.setHeader('Referrer-Policy', 'no-referrer')
|
||||
@@ -96,6 +103,12 @@ function acceptsHtmlNavigation(request, pathname) {
|
||||
return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html'))
|
||||
}
|
||||
|
||||
function isFingerprintedAsset(filePath, staticRoot) {
|
||||
const relativePath = path.relative(staticRoot, filePath)
|
||||
return relativePath.split(path.sep)[0] === 'assets'
|
||||
&& /-[A-Za-z0-9_-]{8,}(?:\.[^.]+)+$/.test(path.basename(relativePath))
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, staticRoot) {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
let pathname
|
||||
@@ -133,8 +146,8 @@ async function serveStatic(request, response, staticRoot) {
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream')
|
||||
response.setHeader('Content-Length', details.size)
|
||||
if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache')
|
||||
else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
if (isFingerprintedAsset(filePath, staticRoot)) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
else response.setHeader('Cache-Control', 'no-cache')
|
||||
if (request.method === 'HEAD') {
|
||||
response.end()
|
||||
return
|
||||
@@ -149,6 +162,13 @@ export async function createWebToolsServer(options = {}) {
|
||||
const catalog = options.catalog || await loadCatalog()
|
||||
const defaults = options.defaults || await loadDefaultNavigation()
|
||||
const logger = options.logger || console
|
||||
let navigationOperation = Promise.resolve()
|
||||
|
||||
function withNavigationLock(operation) {
|
||||
const result = navigationOperation.then(operation, operation)
|
||||
navigationOperation = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
applySecurityHeaders(response)
|
||||
@@ -160,12 +180,13 @@ export async function createWebToolsServer(options = {}) {
|
||||
}
|
||||
if (url.pathname === '/api/navigation') {
|
||||
if (request.method === 'GET') {
|
||||
sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults))
|
||||
sendJson(response, 200, await withNavigationLock(() => readOrCreateNavigation(navigationPath, catalog, defaults)))
|
||||
return
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
if (request.headers['sec-fetch-site'] === 'cross-site') {
|
||||
sendJson(response, 403, { error: '不允许跨站写入配置' })
|
||||
const fetchSite = request.headers['sec-fetch-site']
|
||||
if (fetchSite && fetchSite !== 'same-origin') {
|
||||
sendJson(response, 403, { error: '不允许跨源写入配置' })
|
||||
return
|
||||
}
|
||||
let navigation
|
||||
@@ -175,7 +196,7 @@ export async function createWebToolsServer(options = {}) {
|
||||
sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' })
|
||||
return
|
||||
}
|
||||
await writeJsonAtomic(navigationPath, navigation)
|
||||
await withNavigationLock(() => writeJsonAtomic(navigationPath, navigation))
|
||||
sendJson(response, 200, navigation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
const legacyToolIdAliases = new Map([
|
||||
['ascii-hex', 'utf8-hex'],
|
||||
])
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'))
|
||||
@@ -30,7 +33,9 @@ export function normalizeNavigation(input, catalog, defaults) {
|
||||
if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue
|
||||
seenCategories.add(candidate.id)
|
||||
const tools = []
|
||||
for (const toolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
for (const savedToolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
const migratedToolId = legacyToolIdAliases.get(savedToolId)
|
||||
const toolId = migratedToolId && toolById.has(migratedToolId) ? migratedToolId : savedToolId
|
||||
if (!toolById.has(toolId) || seenTools.has(toolId)) continue
|
||||
seenTools.add(toolId)
|
||||
tools.push(toolId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"categories": [
|
||||
{ "id": "encoding", "tools": ["ascii-hex", "base64"] },
|
||||
{ "id": "encoding", "tools": ["utf8-hex", "base64"] },
|
||||
{ "id": "checksum", "tools": ["crc", "firmware-hash"] },
|
||||
{ "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] },
|
||||
{ "id": "formatting", "tools": ["json", "xml"] },
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"id": "ascii-hex",
|
||||
"title": "ASCII / Hex",
|
||||
"id": "utf8-hex",
|
||||
"title": "UTF-8 / Hex",
|
||||
"category": "encoding",
|
||||
"description": "在 UTF-8 文本与十六进制字节之间转换。",
|
||||
"icon": "Binary"
|
||||
@@ -24,9 +24,9 @@
|
||||
},
|
||||
{
|
||||
"id": "aes-cmac",
|
||||
"title": "AES-128-CMAC",
|
||||
"title": "AES-CMAC",
|
||||
"category": "crypto",
|
||||
"description": "使用 128 位密钥计算消息认证码。",
|
||||
"description": "使用 128、192 或 256 位 AES 密钥计算消息认证码。",
|
||||
"icon": "ShieldCheck"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,17 +25,17 @@ const sections = computed(() => props.navigation.categories.map((navigationCateg
|
||||
<aside id="tool-navigation" class="app-sidebar" aria-label="工具导航">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark"><Wrench :size="17" aria-hidden="true" /></span>
|
||||
<span><strong>Web Tools</strong><small>Personal workspace</small></span>
|
||||
<span><strong>Web Tools</strong><small>本地工具箱</small></span>
|
||||
</div>
|
||||
<label class="sidebar-search">
|
||||
<Search :size="15" aria-hidden="true" />
|
||||
<input v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
<input id="tool-search" name="tool-search" v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
</label>
|
||||
<nav class="tool-navigation">
|
||||
<section v-for="section in sections" :key="section.category!.id">
|
||||
<h2>{{ section.category!.title }}</h2>
|
||||
<RouterLink
|
||||
v-for="(tool, index) in section.tools"
|
||||
v-for="tool in section.tools"
|
||||
:key="tool!.id"
|
||||
:to="`/tools/${tool!.id}`"
|
||||
:class="{ 'is-active': route.meta.toolId === tool!.id }"
|
||||
@@ -43,7 +43,6 @@ const sections = computed(() => props.navigation.categories.map((navigationCateg
|
||||
>
|
||||
<component :is="tool!.iconComponent" :size="16" aria-hidden="true" />
|
||||
<span>{{ tool!.title }}</span>
|
||||
<small>{{ String(index + 1).padStart(2, '0') }}</small>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<p v-if="sections.length === 0" class="empty-search">没有匹配的工具</p>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string
|
||||
name?: string
|
||||
modelValue: string
|
||||
label: string
|
||||
format?: string
|
||||
@@ -30,6 +32,8 @@ function update(event: Event) {
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<textarea
|
||||
:id="props.id"
|
||||
:name="props.name ?? props.id"
|
||||
:value="props.modelValue"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
|
||||
+2
-1
@@ -83,9 +83,10 @@ export function decodeBase64(input: string, urlSafe = false): Uint8Array {
|
||||
if (urlSafe) normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
|
||||
if (!normalized) return new Uint8Array()
|
||||
if (/[^A-Za-z0-9+/=]/.test(normalized)) throw new Error('包含无效的 Base64 字符')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
if (normalized.includes('=') && normalized.length % 4 !== 0) throw new Error('Base64 填充长度无效')
|
||||
if (normalized.length % 4 === 1) throw new Error('Base64 长度无效')
|
||||
normalized = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
try {
|
||||
const binary = atob(normalized)
|
||||
if (btoa(binary) !== normalized) throw new Error('non-canonical')
|
||||
|
||||
+12
-2
@@ -1,8 +1,13 @@
|
||||
import { AES } from '@stablelib/aes'
|
||||
import { CMAC } from '@stablelib/cmac'
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
export const AES_KEY_LENGTHS = [16, 24, 32] as const
|
||||
export type AesKeyLength = typeof AES_KEY_LENGTHS[number]
|
||||
|
||||
export function aesCmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (!AES_KEY_LENGTHS.includes(key.length as AesKeyLength)) {
|
||||
throw new Error(`AES-CMAC 密钥必须是 16、24 或 32 字节,当前为 ${key.length} 字节`)
|
||||
}
|
||||
const aes = new AES(key)
|
||||
const cmac = new CMAC(aes)
|
||||
try {
|
||||
@@ -13,3 +18,8 @@ export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
aes.clean()
|
||||
}
|
||||
}
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
return aesCmac(key, message)
|
||||
}
|
||||
|
||||
@@ -31,10 +31,17 @@ function reflectBigInt(value: bigint, width: number): bigint {
|
||||
}
|
||||
|
||||
function validateParameters(parameters: CrcParameters) {
|
||||
if (!parameters || typeof parameters !== 'object') throw new Error('CRC 参数无效')
|
||||
if (!Number.isInteger(parameters.width) || parameters.width < 8 || parameters.width > 64) {
|
||||
throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
}
|
||||
const mask = (1n << BigInt(parameters.width)) - 1n
|
||||
if (typeof parameters.polynomial !== 'bigint' || typeof parameters.initialValue !== 'bigint' || typeof parameters.xorOut !== 'bigint') {
|
||||
throw new Error('CRC 数值参数必须是 BigInt')
|
||||
}
|
||||
if (typeof parameters.inputReflected !== 'boolean' || typeof parameters.outputReflected !== 'boolean') {
|
||||
throw new Error('CRC 反转参数必须是布尔值')
|
||||
}
|
||||
if (parameters.polynomial <= 0n || parameters.polynomial > mask) throw new Error('CRC 多项式超出当前位宽')
|
||||
if (parameters.initialValue < 0n || parameters.initialValue > mask) throw new Error('CRC 初始值超出当前位宽')
|
||||
if (parameters.xorOut < 0n || parameters.xorOut > mask) throw new Error('CRC 异或输出值超出当前位宽')
|
||||
@@ -148,5 +155,8 @@ export function calculateCrc(data: Uint8Array, parameters: CrcParameters): bigin
|
||||
}
|
||||
|
||||
export function formatCrc(value: bigint, width: number): string {
|
||||
if (!Number.isInteger(width) || width < 8 || width > 64) throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
const mask = (1n << BigInt(width)) - 1n
|
||||
if (typeof value !== 'bigint' || value < 0n || value > mask) throw new Error('CRC 结果超出当前位宽')
|
||||
return value.toString(16).toUpperCase().padStart(Math.ceil(width / 4), '0')
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
import { parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
import { DEFAULT_MAX_INTEL_HEX_BYTES, parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
|
||||
export const MAX_FIRMWARE_FILE_SIZE = 64 * 1024 * 1024
|
||||
export const MAX_FIRMWARE_FILE_SIZE = DEFAULT_MAX_INTEL_HEX_BYTES
|
||||
|
||||
export type FirmwareFormat = 'Intel HEX' | 'BIN'
|
||||
|
||||
@@ -22,10 +22,12 @@ export function parseFirmwareContent(fileName: string, content: string | Uint8Ar
|
||||
const format = getFirmwareFormat(fileName)
|
||||
if (format === 'Intel HEX') {
|
||||
if (typeof content !== 'string') throw new Error('Intel HEX 文件内容必须是文本')
|
||||
if (content.length > MAX_FIRMWARE_FILE_SIZE) throw new Error('Intel HEX 文件内容不能超过 64 MiB')
|
||||
return { format, ...parseIntelHex(content) }
|
||||
}
|
||||
if (typeof content === 'string') throw new Error('BIN 文件内容必须是二进制数据')
|
||||
if (content.length === 0) throw new Error('文件为空')
|
||||
if (content.length > MAX_FIRMWARE_FILE_SIZE) throw new Error('BIN 文件内容不能超过 64 MiB')
|
||||
return {
|
||||
format,
|
||||
blocks: [{ startAddress: 0, data: content }],
|
||||
|
||||
+24
-9
@@ -9,17 +9,19 @@ export interface ParsedIntelHex {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxRecords?: number
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
}
|
||||
|
||||
export const UINT32_ADDRESS_SPACE_SIZE = 0x1_0000_0000
|
||||
export const MAX_UINT32_ADDRESS = UINT32_ADDRESS_SPACE_SIZE - 1
|
||||
export const DEFAULT_MAX_INTEL_HEX_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024
|
||||
export const DEFAULT_MAX_INTEL_HEX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxRecords?: number
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
maxBytes?: number
|
||||
}
|
||||
|
||||
interface DataRecord {
|
||||
startAddress: number
|
||||
@@ -63,9 +65,16 @@ function validateLimit(value: number, label: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
function formatByteLimit(bytes: number): string {
|
||||
return bytes >= 1024 * 1024
|
||||
? `${(bytes / 1024 / 1024).toLocaleString()} MiB`
|
||||
: `${bytes.toLocaleString()} bytes`
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number, maxBytes: number): DataBlock[] {
|
||||
const sorted = [...records].sort((left, right) => left.startAddress - right.startAddress || left.line - right.line)
|
||||
const groups: Array<{ startAddress: number; endAddress: number; firstRecord: number; lastRecord: number }> = []
|
||||
let allocatedBytes = 0
|
||||
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
const record = sorted[index]
|
||||
@@ -73,10 +82,15 @@ function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
const current = groups.at(-1)
|
||||
if (!current || record.startAddress > current.endAddress) {
|
||||
if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`)
|
||||
allocatedBytes += recordEnd - record.startAddress
|
||||
if (allocatedBytes > maxBytes) throw new Error(`Intel HEX 有效数据不能超过 ${formatByteLimit(maxBytes)}`)
|
||||
groups.push({ startAddress: record.startAddress, endAddress: recordEnd, firstRecord: index, lastRecord: index + 1 })
|
||||
continue
|
||||
}
|
||||
current.endAddress = Math.max(current.endAddress, recordEnd)
|
||||
const nextEnd = Math.max(current.endAddress, recordEnd)
|
||||
allocatedBytes += nextEnd - current.endAddress
|
||||
if (allocatedBytes > maxBytes) throw new Error(`Intel HEX 有效数据不能超过 ${formatByteLimit(maxBytes)}`)
|
||||
current.endAddress = nextEnd
|
||||
current.lastRecord = index + 1
|
||||
}
|
||||
|
||||
@@ -103,6 +117,7 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
|
||||
const maxRecords = validateLimit(limits.maxRecords ?? DEFAULT_MAX_INTEL_HEX_RECORDS, 'Intel HEX 记录上限')
|
||||
const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限')
|
||||
const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限')
|
||||
const maxBytes = validateLimit(limits.maxBytes ?? DEFAULT_MAX_INTEL_HEX_BYTES, 'Intel HEX 有效数据上限')
|
||||
const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/)
|
||||
const records: DataRecord[] = []
|
||||
const warnings: string[] = []
|
||||
@@ -148,7 +163,7 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
|
||||
|
||||
if (!records.length) throw new Error('Intel HEX 文件不包含有效数据记录')
|
||||
if (!eofSeen) warnings.push('文件缺少 EOF 记录,已按现有数据计算')
|
||||
const blocks = mergeRecords(records, maxBlocks)
|
||||
const blocks = mergeRecords(records, maxBlocks, maxBytes)
|
||||
return { blocks, totalBytes: blocks.reduce((total, block) => total + block.data.length, 0), warnings }
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -2,11 +2,17 @@ export interface RandomSource {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T
|
||||
}
|
||||
|
||||
export const DEFAULT_RANDOM_BYTES = 16
|
||||
export const MIN_RANDOM_BYTES = 1
|
||||
export const MAX_RANDOM_BYTES = 65_536
|
||||
|
||||
export function secureRandomBytes(length: number, source: RandomSource = globalThis.crypto): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1 || length > 65_536) throw new Error('随机数长度必须是 1 至 65536 字节的整数')
|
||||
if (!Number.isInteger(length) || length < MIN_RANDOM_BYTES || length > MAX_RANDOM_BYTES) {
|
||||
throw new Error(`随机数长度必须是 ${MIN_RANDOM_BYTES} 至 ${MAX_RANDOM_BYTES} 字节的整数`)
|
||||
}
|
||||
if (!source?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const output = new Uint8Array(length)
|
||||
const chunkSize = 65_536
|
||||
const chunkSize = MAX_RANDOM_BYTES
|
||||
for (let offset = 0; offset < output.length; offset += chunkSize) {
|
||||
source.getRandomValues(output.subarray(offset, Math.min(offset + chunkSize, output.length)))
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,5 +1,3 @@
|
||||
import forge from 'node-forge'
|
||||
|
||||
export interface GeneratedRsaKeyPair {
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
@@ -64,7 +62,8 @@ function nativeRandomSeed(length: number): string {
|
||||
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
|
||||
}
|
||||
|
||||
function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
async function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const { default: forge } = await import('node-forge')
|
||||
const prng = forge.random.createInstance()
|
||||
prng.seedFileSync = (needed) => nativeRandomSeed(needed)
|
||||
prng.seedFile = (needed, callback) => callback(null, nativeRandomSeed(needed))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const XML_PARSER_ERROR_NAMESPACES = [
|
||||
'http://www.mozilla.org/newlayout/xml/parsererror.xml',
|
||||
'http://www.w3.org/1999/xhtml',
|
||||
] as const
|
||||
|
||||
interface XmlParserErrorDocument {
|
||||
getElementsByTagName(localName: string): ArrayLike<{
|
||||
localName: string | null
|
||||
namespaceURI: string | null
|
||||
textContent: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface XmlParser {
|
||||
parseFromString(value: string, type: DOMParserSupportedType): XmlParserErrorDocument
|
||||
}
|
||||
|
||||
export function validateXmlDocument(value: string, parser: XmlParser = new DOMParser()): void {
|
||||
if (!value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = parser.parseFromString(value, 'application/xml')
|
||||
const parserError = Array.from(document.getElementsByTagName('parsererror')).find((candidate) => XML_PARSER_ERROR_NAMESPACES.includes(candidate.namespaceURI as typeof XML_PARSER_ERROR_NAMESPACES[number]))
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
+4
-4
@@ -15,20 +15,20 @@ const toolRoutes: RouteRecordRaw[] = tools.map((tool) => ({
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/tools/ascii-hex' },
|
||||
{ path: '/', redirect: '/tools/utf8-hex' },
|
||||
...toolRoutes,
|
||||
{
|
||||
path: '/setting',
|
||||
path: '/tools/setting',
|
||||
name: 'setting',
|
||||
component: () => import('@/views/SettingView.vue'),
|
||||
meta: { title: '工具管理' },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/ascii-hex' },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/utf8-hex' },
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/ascii-hex'
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/utf8-hex'
|
||||
return true
|
||||
})
|
||||
|
||||
+26
-15
@@ -79,6 +79,17 @@ button, input, select, textarea { font: inherit; letter-spacing: 0; }
|
||||
button, a, input, select, textarea { outline-color: var(--accent); }
|
||||
button { color: inherit; }
|
||||
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled, input:disabled, select:disabled, textarea:disabled,
|
||||
button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled='true'], textarea[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.app-layout { height: 100dvh; display: flex; overflow: hidden; background: var(--canvas); }
|
||||
.app-sidebar { width: 236px; flex: 0 0 auto; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); z-index: 30; }
|
||||
.brand-row { min-height: 66px; padding: 0 18px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); }
|
||||
@@ -97,7 +108,6 @@ button { color: inherit; }
|
||||
.tool-navigation a.is-active { color: var(--text); background: var(--surface); border-color: var(--border); box-shadow: 0 1px 2px rgba(20, 24, 28, .04); }
|
||||
.tool-navigation a.is-active::before { position: absolute; left: -1px; width: 3px; height: 18px; content: ''; background: var(--accent); border-radius: 0 2px 2px 0; }
|
||||
.tool-navigation a span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.tool-navigation a small { margin-left: auto; color: var(--faint); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.empty-search { padding: 20px 10px; color: var(--muted); font-size: 12px; }
|
||||
.sidebar-local { min-height: 48px; margin-top: auto; padding: 0 18px; display: flex; align-items: center; gap: 7px; color: var(--muted); border-top: 1px solid var(--border); font-size: 11px; }
|
||||
|
||||
@@ -121,9 +131,8 @@ button { color: inherit; }
|
||||
|
||||
.mode-tabs { width: fit-content; min-height: 36px; padding: 3px; display: flex; align-items: center; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.mode-tab:hover { color: var(--text); }
|
||||
.mode-tab:hover:not(:disabled) { color: var(--text); }
|
||||
.mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); }
|
||||
.mode-tab:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.control-spacer, .action-spacer { flex: 1; }
|
||||
.compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
@@ -132,6 +141,7 @@ button { color: inherit; }
|
||||
.text-input { width: 100%; padding: 0 10px; }
|
||||
.text-input:focus, .select-input:focus, .compact-field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.check-field { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); cursor: pointer; font-size: 12px; }
|
||||
.check-field:has(input:disabled) { color: var(--faint); cursor: not-allowed; }
|
||||
.check-field input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
|
||||
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 38px minmax(0, 1fr); gap: 13px; align-items: center; }
|
||||
@@ -155,18 +165,17 @@ button { color: inherit; }
|
||||
.syntax-comment { color: var(--syntax-comment); font-style: italic; }
|
||||
.syntax-meta { color: var(--syntax-meta); }
|
||||
.swap-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; justify-self: center; color: var(--muted); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 50%; cursor: pointer; }
|
||||
.swap-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.swap-button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.swap-button:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.action-row { min-height: 36px; margin-top: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.action-button { min-height: 34px; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
.action-button:hover { border-color: var(--text); }
|
||||
.action-button:hover:not(:disabled):not(.disabled) { border-color: var(--text); }
|
||||
.action-button.primary { color: var(--primary-text); background: var(--primary); border-color: var(--primary); }
|
||||
.action-button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.primary:hover:not(:disabled):not(.disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.ghost { color: var(--muted); background: transparent; border-color: transparent; }
|
||||
.action-button.ghost:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.ghost:hover:not(:disabled):not(.disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.danger { color: var(--danger); }
|
||||
.action-button:disabled, .action-button.disabled { cursor: not-allowed; opacity: .5; }
|
||||
.action-button.disabled { cursor: not-allowed; opacity: .55; }
|
||||
.privacy-note { min-height: 42px; margin-top: 18px; padding: 9px 13px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--info-soft); border-radius: 6px; font-size: 11px; }
|
||||
.privacy-note strong { color: var(--text); }
|
||||
.status-message { min-height: 38px; margin-top: 12px; padding: 9px 11px; display: flex; align-items: flex-start; gap: 8px; border-radius: 6px; font-size: 12px; }
|
||||
@@ -187,8 +196,8 @@ button { color: inherit; }
|
||||
.single-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 290px; }
|
||||
|
||||
.upload-zone { min-height: 170px; padding: 24px; display: grid; place-items: center; text-align: center; color: var(--muted); background: var(--panel); border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone.is-dragging { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.upload-zone { position: relative; min-height: 170px; padding: 24px; display: grid; place-items: center; overflow: hidden; text-align: center; color: var(--muted); background-color: var(--panel); background-image: none; border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone:focus-within, .upload-zone.is-dragging { color: var(--text); background-color: var(--accent-soft); background-image: none; border-color: var(--accent); }
|
||||
.upload-zone input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.sr-only-file { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
|
||||
.upload-zone strong { display: block; margin-top: 8px; color: var(--text); font-size: 13px; }
|
||||
@@ -202,15 +211,17 @@ button { color: inherit; }
|
||||
.size-option:has(input:checked) { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.size-option input { margin: 0; accent-color: var(--accent); }
|
||||
.file-meta { margin-top: 12px; color: var(--muted); font-size: 11px; }
|
||||
.compact-upload { min-height: 138px; }
|
||||
.compact-upload { min-height: 138px; margin-bottom: 16px; }
|
||||
.firmware-summary { min-height: 58px; margin-bottom: 16px; padding: 10px 12px; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.firmware-summary > div { min-width: 0; margin-right: auto; }
|
||||
.firmware-summary strong, .firmware-summary span { display: block; overflow-wrap: anywhere; }
|
||||
.firmware-summary strong { font-size: 12px; font-weight: 600; }
|
||||
.firmware-summary span { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(110px, 1fr)) auto auto; gap: 10px; align-items: end; }
|
||||
.crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) minmax(96px, .65fr) repeat(3, minmax(145px, 1fr)); gap: 10px; align-items: end; }
|
||||
.parameter-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||
.parameter-field .select-input, .parameter-field .text-input { width: 100%; font-size: 12px; font-weight: 400; }
|
||||
.crc-config .parameter-field > span { white-space: nowrap; }
|
||||
.crc-reflection-options { grid-column: 1 / -1; min-height: 34px; display: flex; align-items: center; flex-wrap: wrap; column-gap: 22px; row-gap: 8px; }
|
||||
.crc-check { min-height: 34px; white-space: nowrap; }
|
||||
.crc-results { margin-top: 16px; }
|
||||
.crc-total { min-height: 58px; padding: 10px 13px; display: flex; align-items: center; gap: 14px; color: var(--muted); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 7px; font-size: 11px; }
|
||||
@@ -272,12 +283,12 @@ button { color: inherit; }
|
||||
.row-actions { display: flex; gap: 3px; }
|
||||
.row-action { width: 30px; height: 30px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
|
||||
.row-action:hover:not(:disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.row-action:disabled { opacity: .3; cursor: default; }
|
||||
.row-action:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.save-state { margin-top: 14px; }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.crc-config { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.crc-config .preset-field { grid-column: 1 / -1; }
|
||||
.crc-config .preset-field, .crc-config .crc-reflection-options { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
|
||||
+26
-12
@@ -6,10 +6,11 @@ import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { bytesToHex, parseHex, textToBytes } from '@/lib/bytes'
|
||||
import { aes128Cmac } from '@/lib/cmac'
|
||||
import { aesCmac, type AesKeyLength } from '@/lib/cmac'
|
||||
|
||||
type MessageMode = 'hex' | 'text'
|
||||
const keyInput = ref('')
|
||||
const keyLength = ref<AesKeyLength>(16)
|
||||
const messageMode = ref<MessageMode>('hex')
|
||||
const message = ref('')
|
||||
const output = ref('')
|
||||
@@ -17,12 +18,18 @@ const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const messageModes = [{ value: 'hex' as const, label: 'Hex 消息' }, { value: 'text' as const, label: 'UTF-8 文本' }]
|
||||
const keyLengthOptions: Array<{ value: AesKeyLength, label: string }> = [
|
||||
{ value: 16, label: '128 位(AES-128)' },
|
||||
{ value: 24, label: '192 位(AES-192)' },
|
||||
{ value: 32, label: '256 位(AES-256)' },
|
||||
]
|
||||
const keyLengthBits = computed(() => keyLength.value * 8)
|
||||
|
||||
const keyState = computed(() => {
|
||||
if (!keyInput.value.trim()) return { bytes: 0, valid: false }
|
||||
try {
|
||||
const bytes = parseHex(keyInput.value).length
|
||||
return { bytes, valid: bytes === 16 }
|
||||
return { bytes, valid: bytes === keyLength.value }
|
||||
}
|
||||
catch { return { bytes: null, valid: false } }
|
||||
})
|
||||
@@ -33,15 +40,15 @@ function clearResult() {
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([keyInput, message, messageMode, uppercase], clearResult)
|
||||
watch([keyInput, keyLength, message, messageMode, uppercase], clearResult)
|
||||
|
||||
function calculate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const key = parseHex(keyInput.value, 16)
|
||||
const key = parseHex(keyInput.value, keyLength.value)
|
||||
const data = messageMode.value === 'hex' ? parseHex(message.value) : textToBytes(message.value)
|
||||
output.value = bytesToHex(aes128Cmac(key, data), '', uppercase.value)
|
||||
output.value = bytesToHex(aesCmac(key, data), '', uppercase.value)
|
||||
success.value = 'CMAC 计算完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
@@ -57,24 +64,31 @@ function clearAll() { keyInput.value = ''; message.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="AES-128-CMAC" description="使用 128 位密钥计算消息认证码,结果按 RFC 4493 规则生成。">
|
||||
<ToolPage category="加密计算" title="AES-CMAC" description="使用 128、192 或 256 位 AES 密钥计算 128 位消息认证码。">
|
||||
<div class="tool-controls">
|
||||
<label class="parameter-field"><span>密钥长度(Key Length)</span>
|
||||
<select id="aes-cmac-key-length" name="aes-cmac-key-length" v-model.number="keyLength" class="select-input">
|
||||
<option v-for="option in keyLengthOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label for="cmac-key">128 位密钥</label>
|
||||
<textarea id="cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或 00112233445566778899AABBCCDDEEFF" />
|
||||
<label for="cmac-key">{{ keyLengthBits }} 位密钥</label>
|
||||
<textarea id="cmac-key" name="aes-cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或连续十六进制字节" />
|
||||
<div class="field-help">
|
||||
支持连续 Hex、空格、换行、英文/中文逗号及每字节可选的 0x 前缀。
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/16 字节` }}</span>
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/${keyLength} 字节` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="messageMode" :options="messageModes" label="消息格式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
<label class="check-field"><input id="aes-cmac-uppercase" name="aes-cmac-uppercase" v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<TextEditor id="aes-cmac-message" v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<span />
|
||||
<TextEditor v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
<TextEditor id="aes-cmac-output" v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="calculate"><Play :size="15" />计算 CMAC</button>
|
||||
|
||||
@@ -103,15 +103,15 @@ function clearAll() { input.value = ''; clearConversion() }
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换模式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
<label class="check-field"><input id="base64-url-safe" name="base64-url-safe" v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
</div>
|
||||
<div v-if="mode === 'decode'" class="tool-controls base64-output-controls">
|
||||
<ModeTabs v-model="decodeOutputMode" :options="decodeOutputModes" label="解码输出格式" />
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<TextEditor id="base64-input" v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" :disabled="mode === 'decode' && decodeOutputMode !== 'text'" :title="mode === 'decode' && decodeOutputMode !== 'text' ? '仅 UTF-8 文本结果可交换' : ''" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
<TextEditor id="base64-output" v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
|
||||
+17
-15
@@ -70,11 +70,11 @@ function parseParameter(value: string, label: string): bigint {
|
||||
function getParameters(): CrcParameters {
|
||||
return {
|
||||
width: width.value,
|
||||
polynomial: parseParameter(polynomial.value, 'Poly'),
|
||||
initialValue: parseParameter(initialValue.value, 'Init'),
|
||||
polynomial: parseParameter(polynomial.value, '多项式(Poly)'),
|
||||
initialValue: parseParameter(initialValue.value, '初始值(Init)'),
|
||||
inputReflected: inputReflected.value,
|
||||
outputReflected: outputReflected.value,
|
||||
xorOut: parseParameter(xorOut.value, 'XorOut'),
|
||||
xorOut: parseParameter(xorOut.value, '异或输出(XorOut)'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,30 +221,32 @@ onBeforeUnmount(destroyWorker)
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="CRC" description="解析 BIN 或 Intel HEX 有效数据,并使用预设或自定义参数计算 CRC。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,.bin,application/octet-stream" :disabled="parsing" @change="onFileInput" />
|
||||
<input id="crc-file" name="crc-file" ref="fileInput" type="file" accept=".hex,.ihx,.bin,application/octet-stream" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileUp :size="28" aria-hidden="true" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 HEX / BIN 文件' }}</strong><span>Intel HEX 会校验记录并识别多个数据块</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>{{ firmware.format }} · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,.bin,application/octet-stream" @change="onFileInput" /></label>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input id="crc-replacement-file" name="crc-replacement-file" class="sr-only-file" type="file" accept=".hex,.ihx,.bin,application/octet-stream" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
|
||||
<div class="crc-config">
|
||||
<label class="parameter-field preset-field"><span>参数预设</span>
|
||||
<select v-model="selectedPreset" class="select-input" :disabled="calculating" @change="applyPreset">
|
||||
<label class="parameter-field preset-field"><span>参数预设(Preset)</span>
|
||||
<select id="crc-preset" name="crc-preset" v-model="selectedPreset" class="select-input" :disabled="calculating" @change="applyPreset">
|
||||
<option v-for="preset in CRC_PRESETS" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
<option value="custom">自定义参数</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Width</span>
|
||||
<select v-model.number="width" class="select-input" :disabled="calculating" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
<label class="parameter-field"><span>位宽(Width)</span>
|
||||
<select id="crc-width" name="crc-width" v-model.number="width" class="select-input" :disabled="calculating" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Poly (Hex)</span><input v-model="polynomial" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>Init (Hex)</span><input v-model="initialValue" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>XorOut (Hex)</span><input v-model="xorOut" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="check-field crc-check"><input v-model="inputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefIn</label>
|
||||
<label class="check-field crc-check"><input v-model="outputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefOut</label>
|
||||
<label class="parameter-field"><span>多项式(Poly,Hex)</span><input id="crc-polynomial" name="crc-polynomial" v-model="polynomial" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>初始值(Init,Hex)</span><input id="crc-initial-value" name="crc-initial-value" v-model="initialValue" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>异或输出(XorOut,Hex)</span><input id="crc-xor-out" name="crc-xor-out" v-model="xorOut" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<div class="crc-reflection-options" role="group" aria-label="位反转选项">
|
||||
<label class="check-field crc-check"><input id="crc-ref-in" name="crc-ref-in" v-model="inputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />输入位反转(RefIn)</label>
|
||||
<label class="check-field crc-check"><input id="crc-ref-out" name="crc-ref-out" v-model="outputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />输出位反转(RefOut)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
@@ -257,7 +259,7 @@ onBeforeUnmount(destroyWorker)
|
||||
<div class="crc-total"><span>总有效数据 · 按地址升序拼接,不填充空洞</span><strong>{{ combinedResult }}</strong></div>
|
||||
<div class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Block</th><th>起始地址</th><th>结束地址</th><th>字节数</th><th>CRC</th></tr></thead>
|
||||
<thead><tr><th>数据块(Block)</th><th>起始地址(Start Address)</th><th>结束地址(End Address)</th><th>字节数(Length)</th><th>CRC</th></tr></thead>
|
||||
<tbody><tr v-for="(block, index) in blockResults" :key="block.startAddress"><td>#{{ index + 1 }}</td><td>{{ formatAddress(block.startAddress) }}</td><td>{{ formatAddress(block.endAddress) }}</td><td>{{ block.length.toLocaleString() }}</td><td class="crc-value">{{ block.crc }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -192,12 +192,12 @@ onBeforeUnmount(destroyWorker)
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="固件 Hash" description="解析 Intel HEX 有效数据,计算 SHA-256 / SHA-512,可选择加入每个 Block 的地址和长度校验头。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,text/plain" :disabled="parsing" @change="onFileInput" />
|
||||
<input id="firmware-hash-file" name="firmware-hash-file" ref="fileInput" type="file" accept=".hex,.ihx,text/plain" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileKey2 :size="28" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 Intel HEX 文件' }}</strong><span>校验记录、识别多个数据块,不填充地址空洞</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>Intel HEX · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block · {{ formatFileSize(firmware.fileSize) }}</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,text/plain" @change="onFileInput" /></label>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input id="firmware-hash-replacement-file" name="firmware-hash-replacement-file" class="sr-only-file" type="file" accept=".hex,.ihx,text/plain" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,11 +44,11 @@ function clearAll() { input.value = ''; clearResult() }
|
||||
<ToolPage category="格式化" title="JSON" description="格式化、压缩并校验 JSON 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
<select id="json-indent" name="json-indent" v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<TextEditor id="json-input" v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="json" format="JSON" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
|
||||
@@ -146,7 +146,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<ToolPage category="图像处理" title="PNG → ICO" description="在浏览器本地缩放 PNG,并生成包含多个尺寸的 ICO 文件。">
|
||||
<label v-if="!file" class="upload-zone" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<input id="png-ico-file" name="png-ico-file" type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<span><ImagePlus :size="28" aria-hidden="true" /><strong>选择或拖入 PNG 图片</strong><span>透明背景会保留,文件不会上传</span></span>
|
||||
</label>
|
||||
<div v-else class="image-workspace">
|
||||
@@ -155,13 +155,13 @@ onBeforeUnmount(() => {
|
||||
<h2>ICO 尺寸</h2>
|
||||
<div class="size-grid">
|
||||
<label v-for="size in availableSizes" :key="size" class="size-option">
|
||||
<input type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
<input :id="`png-ico-size-${size}`" :name="`png-ico-size-${size}`" type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="file-meta">{{ file.name }} · {{ dimensions }} · {{ (file.size / 1024).toFixed(1) }} KB</p>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="converting || !imageReady" @click="convert"><Download :size="15" />{{ converting ? '正在生成…' : imageReady ? '生成并下载 ICO' : '正在读取图片…' }}</button>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input id="png-ico-replacement-file" name="png-ico-replacement-file" class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" :disabled="converting" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, type ByteDisplayFormat } from '@/lib/bytes'
|
||||
import { secureRandomBytes } from '@/lib/random'
|
||||
import { DEFAULT_RANDOM_BYTES, MAX_RANDOM_BYTES, MIN_RANDOM_BYTES, secureRandomBytes } from '@/lib/random'
|
||||
|
||||
const length = ref(8)
|
||||
const length = ref(DEFAULT_RANDOM_BYTES)
|
||||
const outputFormat = ref<ByteDisplayFormat>('continuous-hex')
|
||||
const uppercase = ref(true)
|
||||
const bytes = shallowRef<Uint8Array>()
|
||||
@@ -55,11 +55,11 @@ watch(length, clearAll)
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="安全随机数" description="使用浏览器密码学随机源生成指定字节长度的随机数据。">
|
||||
<div class="random-controls">
|
||||
<label class="parameter-field"><span>字节长度</span><input v-model.number="length" class="text-input" type="number" min="1" max="65536" step="1" /></label>
|
||||
<label class="parameter-field"><span>字节长度</span><input id="random-length" name="random-length" v-model.number="length" class="text-input" type="number" :min="MIN_RANDOM_BYTES" :max="MAX_RANDOM_BYTES" step="1" /></label>
|
||||
<div class="parameter-field random-format-field"><span>输出格式</span><ModeTabs v-model="outputFormat" :options="outputFormats" label="输出格式" /></div>
|
||||
<label class="check-field random-check"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
<label class="check-field random-check"><input id="random-uppercase" name="random-uppercase" v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<TextEditor :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<TextEditor id="random-output" :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="generate"><Dices :size="15" />生成随机数</button>
|
||||
<button class="action-button" type="button" :disabled="!output" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, KeyRound, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
@@ -17,6 +17,7 @@ const result = shallowRef<GeneratedRsaKeyPair>()
|
||||
const generating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let generationVersion = 0
|
||||
const outputModes = [
|
||||
{ value: 'public' as const, label: '公钥 PEM' },
|
||||
{ value: 'private' as const, label: '私钥 PEM' },
|
||||
@@ -52,15 +53,19 @@ async function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
generating.value = true
|
||||
const version = ++generationVersion
|
||||
try {
|
||||
result.value = await generateRsaKeyPair(modulusLength.value)
|
||||
const generated = await generateRsaKeyPair(modulusLength.value)
|
||||
if (version !== generationVersion) return
|
||||
result.value = generated
|
||||
outputMode.value = 'public'
|
||||
success.value = `RSA 密钥对已生成 · ${result.value.source}`
|
||||
} catch (reason) {
|
||||
if (version !== generationVersion) return
|
||||
result.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : 'RSA 密钥生成失败'
|
||||
} finally {
|
||||
generating.value = false
|
||||
if (version === generationVersion) generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +75,15 @@ async function copyValue(value: string, label: string) {
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
generationVersion += 1
|
||||
result.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
generationVersion += 1
|
||||
})
|
||||
|
||||
watch(modulusLength, clearAll)
|
||||
</script>
|
||||
@@ -83,7 +92,7 @@ watch(modulusLength, clearAll)
|
||||
<ToolPage category="加密计算" title="RSA 密钥对" description="使用安全随机源生成 RSA 密钥对,并导出标准 PEM 或公钥 N/E 参数。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">密钥长度
|
||||
<select v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
<select id="rsa-modulus-length" name="rsa-modulus-length" v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
</label>
|
||||
<span class="fixed-parameter">Public exponent <strong>65537 · 0x010001</strong></span>
|
||||
</div>
|
||||
@@ -103,16 +112,16 @@ watch(modulusLength, clearAll)
|
||||
</div>
|
||||
<div class="rsa-parameter-grid">
|
||||
<section>
|
||||
<TextEditor :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<TextEditor id="rsa-modulus-output" :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(modulusOutput, 'N 参数')"><Copy :size="15" />复制 N</button></div>
|
||||
</section>
|
||||
<section>
|
||||
<TextEditor :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<TextEditor id="rsa-exponent-output" :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(exponentOutput, 'E 参数')"><Copy :size="15" />复制 E</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<TextEditor v-else :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
<TextEditor v-else id="rsa-pem-output" :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
</template>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
|
||||
@@ -79,23 +79,23 @@ function clearAll() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="ASCII / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<ToolPage category="编码转换" title="UTF-8 / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换方向" />
|
||||
<span class="control-spacer" />
|
||||
<label v-if="mode === 'text-to-hex'" class="compact-field">分隔符
|
||||
<select v-model="delimiter">
|
||||
<select id="utf8-hex-delimiter" name="utf8-hex-delimiter" v-model="delimiter">
|
||||
<option value=" ">空格</option>
|
||||
<option value="">无</option>
|
||||
<option value=",">逗号</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input id="utf8-hex-uppercase" name="utf8-hex-uppercase" v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<TextEditor id="utf8-hex-input" v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
<TextEditor id="utf8-hex-output" v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
+7
-12
@@ -6,8 +6,9 @@ import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { validateXmlDocument } from '@/lib/xml'
|
||||
|
||||
const input = ref('<tools><tool id="hex">ASCII / Hex</tool><tool id="cmac">AES-128-CMAC</tool></tools>')
|
||||
const input = ref('<tools><tool id="hex">UTF-8 / Hex</tool><tool id="cmac">AES-CMAC</tool></tools>')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
@@ -21,25 +22,19 @@ function clearResult() {
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function validateXml() {
|
||||
if (!input.value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = new DOMParser().parseFromString(input.value, 'application/xml')
|
||||
const parserError = document.querySelector('parsererror')
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
function format() {
|
||||
try {
|
||||
validateXml()
|
||||
validateXmlDocument(input.value)
|
||||
output.value = xmlFormatter(input.value, { indentation: ' '.repeat(Number(indent.value)), lineSeparator: '\n', strictMode: true })
|
||||
error.value = ''; success.value = 'XML 格式正确'
|
||||
} catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { validateXml(); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
try { validateXmlDocument(input.value); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { validateXml(); error.value = ''; success.value = 'XML 格式正确' }
|
||||
try { validateXmlDocument(input.value); error.value = ''; success.value = 'XML 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
@@ -50,11 +45,11 @@ function clearAll() { input.value = ''; clearResult() }
|
||||
<ToolPage category="格式化" title="XML" description="格式化、压缩并校验 XML 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
<select id="xml-indent" name="xml-indent" v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<TextEditor id="xml-input" v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="xml" format="XML" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
|
||||
@@ -36,9 +36,10 @@ export const tools = (catalogData.tools as CatalogTool[]).map((tool) => {
|
||||
|
||||
export const categoryById = new Map(categories.map((category) => [category.id, category]))
|
||||
export const toolById = new Map(tools.map((tool) => [tool.id, tool]))
|
||||
const reservedToolIds = new Set(['setting'])
|
||||
|
||||
export const toolComponents: Record<string, Component> = {
|
||||
'ascii-hex': defineAsyncComponent(() => import('./AsciiHexTool.vue')),
|
||||
'utf8-hex': defineAsyncComponent(() => import('./Utf8HexTool.vue')),
|
||||
base64: defineAsyncComponent(() => import('./Base64Tool.vue')),
|
||||
'aes-cmac': defineAsyncComponent(() => import('./AesCmacTool.vue')),
|
||||
'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')),
|
||||
@@ -59,6 +60,7 @@ export function assertToolRegistryConsistency() {
|
||||
|
||||
const toolIds = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
if (reservedToolIds.has(tool.id)) throw new Error(`工具 ID 为保留值:${tool.id}`)
|
||||
if (toolIds.has(tool.id)) throw new Error(`工具 ID 重复:${tool.id}`)
|
||||
if (!categoryIds.has(tool.category)) throw new Error(`工具 ${tool.id} 使用了不存在的分类 ${tool.category}`)
|
||||
toolIds.add(tool.id)
|
||||
|
||||
@@ -145,7 +145,7 @@ onMounted(async () => {
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<span class="tool-row-icon"><component :is="toolById.get(toolId)?.iconComponent" :size="15" /></span>
|
||||
<strong>{{ toolById.get(toolId)?.title }}</strong>
|
||||
<select class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<select :id="`setting-tool-category-${toolId}`" :name="`setting-tool-category-${toolId}`" class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.title }}</option>
|
||||
</select>
|
||||
<span class="row-actions">
|
||||
|
||||
@@ -50,6 +50,9 @@ describe('UTF-8 and Base64', () => {
|
||||
it('rejects invalid Base64', () => {
|
||||
expect(() => decodeBase64('abcde')).toThrow('长度')
|
||||
expect(() => decodeBase64('ab*c')).toThrow('无效')
|
||||
expect(() => decodeBase64('TQ=')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('TWE==')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('T=Q=')).toThrow('填充位置')
|
||||
expect(() => decodeBase64('Zh==')).toThrow('无效')
|
||||
expect(() => decodeBase64('Zm9=')).toThrow('无效')
|
||||
})
|
||||
|
||||
@@ -25,4 +25,11 @@ describe('tool catalog consistency', () => {
|
||||
expect(navigationToolIds.sort()).toEqual(catalog.tools.map(({ id }) => id).sort())
|
||||
expect(new Set(navigationToolIds).size).toBe(navigationToolIds.length)
|
||||
})
|
||||
|
||||
it('places checksum tools before cryptographic tools by default', () => {
|
||||
const categoryIds = defaultNavigation.categories.map(({ id }) => id)
|
||||
expect(categoryIds.indexOf('checksum')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('crypto')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('checksum')).toBeLessThan(categoryIds.indexOf('crypto'))
|
||||
})
|
||||
})
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bytesToHex, parseHex } from '../src/lib/bytes'
|
||||
import { aes128Cmac } from '../src/lib/cmac'
|
||||
import { aes128Cmac, aesCmac } from '../src/lib/cmac'
|
||||
|
||||
describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
const key = parseHex('2B7E151628AED2A6ABF7158809CF4F3C')
|
||||
@@ -15,3 +15,42 @@ describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
expect(bytesToHex(aes128Cmac(key, parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AES-CMAC NIST SP 800-38B vectors', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'AES-192 empty message',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '',
|
||||
expected: 'D17DDF46ADAACDE531CAC483DE7A9367',
|
||||
},
|
||||
{
|
||||
name: 'AES-192 one block',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '9E99A7BF31E710900662F65E617C5184',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 empty message',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '',
|
||||
expected: '028962F61B7BF89EFC6B551F4667D983',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 one block',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '28A7023F452E8F82BD4BF28D8C37C35C',
|
||||
},
|
||||
])('matches $name', ({ key, message, expected }) => {
|
||||
expect(bytesToHex(aesCmac(parseHex(key), parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([0, 15, 17, 23, 25, 31, 33])('rejects a %s-byte key', (length) => {
|
||||
expect(() => aesCmac(new Uint8Array(length), new Uint8Array())).toThrow('16、24 或 32 字节')
|
||||
})
|
||||
|
||||
it('keeps the AES-128 compatibility wrapper strict', () => {
|
||||
expect(() => aes128Cmac(new Uint8Array(24), new Uint8Array())).toThrow('AES-128 密钥必须是 16 字节')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,14 @@ describe('CRC', () => {
|
||||
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
|
||||
})
|
||||
|
||||
it('rejects malformed runtime parameters instead of coercing them', () => {
|
||||
const preset = CRC_PRESETS[0]
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, inputReflected: undefined as never })).toThrow('布尔值')
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, polynomial: 7 as never })).toThrow('BigInt')
|
||||
expect(() => formatCrc(0x100n, 8)).toThrow('超出当前位宽')
|
||||
expect(() => formatCrc(0n, 7)).toThrow('位宽')
|
||||
})
|
||||
|
||||
it('reports both the combined CRC and each block CRC', () => {
|
||||
const parameters = CRC_PRESETS.find((preset) => preset.id === 'crc-32-iso-hdlc')!
|
||||
const blocks = [
|
||||
|
||||
@@ -95,4 +95,28 @@ describe('deployment configuration', () => {
|
||||
expect(agentGuide).toContain('git status --short --branch')
|
||||
expect(completeReadme).toContain('## AI 接手清单')
|
||||
})
|
||||
|
||||
it('keeps the management page under the reserved tools namespace', async () => {
|
||||
const router = await readProjectFile('src/router.ts')
|
||||
|
||||
expect(router).toContain("path: '/tools/setting'")
|
||||
expect(router).not.toContain("path: '/setting'")
|
||||
})
|
||||
|
||||
it('uses the UTF-8 / Hex tool ID as the canonical default route', async () => {
|
||||
const [router, catalogText, navigationText, registry] = await Promise.all([
|
||||
readProjectFile('src/router.ts'),
|
||||
readProjectFile('shared/tool-catalog.json'),
|
||||
readProjectFile('shared/default-navigation.json'),
|
||||
readProjectFile('src/tools/registry.ts'),
|
||||
])
|
||||
const catalog = JSON.parse(catalogText)
|
||||
const navigation = JSON.parse(navigationText)
|
||||
|
||||
expect(catalog.tools.find(({ title }) => title === 'UTF-8 / Hex')?.id).toBe('utf8-hex')
|
||||
expect(navigation.categories.flatMap(({ tools }) => tools)).toContain('utf8-hex')
|
||||
expect(router).toContain("redirect: '/tools/utf8-hex'")
|
||||
expect(router).not.toContain("'/tools/ascii-hex'")
|
||||
expect(registry).toContain("'utf8-hex': defineAsyncComponent(() => import('./Utf8HexTool.vue'))")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function findVueFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(entries.map((entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return findVueFiles(entryPath)
|
||||
return entry.isFile() && entry.name.endsWith('.vue') ? [entryPath] : []
|
||||
}))
|
||||
return files.flat()
|
||||
}
|
||||
|
||||
describe('form field identity', () => {
|
||||
it('gives every native form field an id and name', async () => {
|
||||
const vueFiles = await findVueFiles(path.join(projectRoot, 'src'))
|
||||
const missing = []
|
||||
|
||||
for (const filePath of vueFiles) {
|
||||
const source = await readFile(filePath, 'utf8')
|
||||
for (const match of source.matchAll(/<(?:input|select|textarea)\b[^>]*>/gs)) {
|
||||
const tag = match[0]
|
||||
const line = source.slice(0, match.index).split('\n').length
|
||||
if (!/\s(?::)?id=/.test(tag) || !/\s(?::)?name=/.test(tag)) {
|
||||
missing.push(`${path.relative(projectRoot, filePath)}:${line} ${tag.replace(/\s+/g, ' ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_INTEL_HEX_BYTES,
|
||||
DEFAULT_MAX_INTEL_HEX_RECORDS,
|
||||
MAX_UINT32_ADDRESS,
|
||||
parseIntelHex,
|
||||
@@ -63,6 +64,16 @@ describe('Intel HEX structural limits', () => {
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: 1.5 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.POSITIVE_INFINITY })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.MAX_SAFE_INTEGER + 1 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxBytes: 0 })).toThrow('正整数')
|
||||
expect(DEFAULT_MAX_INTEL_HEX_BYTES).toBe(64 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('rejects a merged data block before allocating beyond the byte limit', () => {
|
||||
expect(() => parseIntelHex([
|
||||
record(0, 0, [0xaa]),
|
||||
record(1, 0, [0xbb]),
|
||||
eof,
|
||||
].join('\n'), { maxBytes: 1 })).toThrow('有效数据不能超过 1 bytes')
|
||||
})
|
||||
|
||||
it('rejects records after EOF and malformed control records', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeNavigation,
|
||||
readOrCreateNavigation,
|
||||
validateNavigation,
|
||||
writeJsonAtomic,
|
||||
} from '../server/navigation.mjs'
|
||||
@@ -41,6 +42,34 @@ describe('navigation config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the legacy ASCII/Hex ID without changing its saved position', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = path.join(directory, 'navigation.json')
|
||||
const utf8Catalog = {
|
||||
categories: [{ id: 'encoding' }],
|
||||
tools: [
|
||||
{ id: 'utf8-hex', category: 'encoding' },
|
||||
{ id: 'base64', category: 'encoding' },
|
||||
],
|
||||
}
|
||||
const utf8Defaults = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['utf8-hex', 'base64'] }],
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'ascii-hex'] }],
|
||||
}))
|
||||
|
||||
const expected = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'utf8-hex'] }],
|
||||
}
|
||||
await expect(readOrCreateNavigation(filePath, utf8Catalog, utf8Defaults)).resolves.toEqual(expected)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects duplicate or incomplete configurations', () => {
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复')
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部')
|
||||
|
||||
+32
-2
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes } from '../src/lib/bytes'
|
||||
import { secureRandomBytes, type RandomSource } from '../src/lib/random'
|
||||
import {
|
||||
DEFAULT_RANDOM_BYTES,
|
||||
MAX_RANDOM_BYTES,
|
||||
MIN_RANDOM_BYTES,
|
||||
secureRandomBytes,
|
||||
type RandomSource,
|
||||
} from '../src/lib/random'
|
||||
|
||||
describe('secure random bytes', () => {
|
||||
it('uses the supplied cryptographic source and preserves requested length', () => {
|
||||
@@ -12,8 +18,32 @@ describe('secure random bytes', () => {
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(DEFAULT_RANDOM_BYTES).toBe(16)
|
||||
expect(secureRandomBytes(4, source)).toEqual(Uint8Array.from([0, 1, 2, 3]))
|
||||
expect(() => secureRandomBytes(0, source)).toThrow('1 至 65536')
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, MAX_RANDOM_BYTES + 1])('rejects invalid length %s', (length) => {
|
||||
expect(() => secureRandomBytes(length, crypto)).toThrow(`${MIN_RANDOM_BYTES} 至 ${MAX_RANDOM_BYTES}`)
|
||||
})
|
||||
|
||||
it('accepts both length boundaries without exceeding the browser call limit', () => {
|
||||
const calls: number[] = []
|
||||
const source: RandomSource = {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T {
|
||||
calls.push(array.byteLength)
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(secureRandomBytes(MIN_RANDOM_BYTES, source)).toHaveLength(MIN_RANDOM_BYTES)
|
||||
expect(secureRandomBytes(MAX_RANDOM_BYTES, source)).toHaveLength(MAX_RANDOM_BYTES)
|
||||
expect(calls).toEqual([MIN_RANDOM_BYTES, MAX_RANDOM_BYTES])
|
||||
})
|
||||
|
||||
it('fails closed when the cryptographic source is unavailable or throws', () => {
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, null as unknown as RandomSource)).toThrow('不支持安全随机数')
|
||||
const failure = new Error('entropy source failed')
|
||||
const source: RandomSource = { getRandomValues: () => { throw failure } }
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, source)).toThrow(failure)
|
||||
})
|
||||
|
||||
it('formats the same bytes for OpenSSL and source-code styles', () => {
|
||||
|
||||
+18
-1
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatBytes, parseHex } from '../src/lib/bytes'
|
||||
import { base64UrlToHex, formatPem, generateRsaKeyPair } from '../src/lib/rsa'
|
||||
|
||||
describe('RSA key generation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('formats DER and JWK integer values', () => {
|
||||
expect(formatPem(Uint8Array.from([0, 1, 2, 3]), 'PUBLIC KEY')).toBe('-----BEGIN PUBLIC KEY-----\nAAECAw==\n-----END PUBLIC KEY-----\n')
|
||||
expect(base64UrlToHex('AQAB')).toBe('010001')
|
||||
@@ -17,4 +21,17 @@ describe('RSA key generation', () => {
|
||||
expect(formatBytes(parseHex(result.exponentHex), { format: 'byte-array' })).toBe('0x01, 0x00, 0x01')
|
||||
expect(['Web Crypto', 'node-forge']).toContain(result.source)
|
||||
}, 20_000)
|
||||
|
||||
it('uses the node-forge fallback with a cryptographic random source when SubtleCrypto is unavailable', async () => {
|
||||
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
|
||||
vi.stubGlobal('crypto', { getRandomValues })
|
||||
|
||||
const result = await generateRsaKeyPair(2048)
|
||||
|
||||
expect(result.source).toBe('node-forge')
|
||||
expect(result.publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----/)
|
||||
expect(result.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
|
||||
expect(result.modulusHex).toHaveLength(512)
|
||||
expect(result.exponentHex).toBe('010001')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
+66
-2
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { assertProductionBuild, createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
import { assertProductionBuild, createWebToolsServer, CSP_NONCE_BYTES, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const servers = []
|
||||
@@ -34,8 +34,11 @@ async function createFixtureServer() {
|
||||
const dataDir = path.join(directory, 'data')
|
||||
await writeFile(path.join(directory, 'placeholder'), '')
|
||||
await mkdir(staticRoot, { recursive: true })
|
||||
await mkdir(path.join(staticRoot, 'assets'), { recursive: true })
|
||||
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
|
||||
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'app-ABCD1234.js'), 'console.log("hashed")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'unversioned.js'), 'console.log("stable")')
|
||||
await writeFile(path.join(staticRoot, 'site.webmanifest'), '{}')
|
||||
const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } })
|
||||
servers.push(server)
|
||||
@@ -68,6 +71,26 @@ describe('HTTP server', () => {
|
||||
expect(extensionlessAsset.status).toBe(404)
|
||||
})
|
||||
|
||||
it('uses a unique cryptographic CSP nonce without allowing arbitrary inline scripts', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const first = await fetch(`${baseUrl}/`)
|
||||
const second = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
|
||||
const firstPolicy = first.headers.get('content-security-policy') ?? ''
|
||||
const secondPolicy = second.headers.get('content-security-policy') ?? ''
|
||||
const noncePattern = /script-src 'self' 'nonce-([A-Za-z0-9+/]+={0,2})'/
|
||||
const firstNonce = firstPolicy.match(noncePattern)?.[1]
|
||||
const secondNonce = secondPolicy.match(noncePattern)?.[1]
|
||||
|
||||
expect(firstNonce).toBeTruthy()
|
||||
expect(secondNonce).toBeTruthy()
|
||||
expect(Buffer.from(firstNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(Buffer.from(secondNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(firstNonce).not.toBe(secondNonce)
|
||||
expect(firstPolicy).not.toContain("'unsafe-inline'")
|
||||
expect(firstPolicy).toContain("object-src 'none'")
|
||||
expect(firstPolicy).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('returns no-store JSON health and API responses', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const health = await fetch(`${baseUrl}/healthz`)
|
||||
@@ -85,6 +108,18 @@ describe('HTTP server', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/manifest+json; charset=utf-8')
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('only gives immutable caching to fingerprinted build assets', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const stableAsset = await fetch(`${baseUrl}/app.js`)
|
||||
const nestedStableAsset = await fetch(`${baseUrl}/assets/unversioned.js`)
|
||||
const fingerprintedAsset = await fetch(`${baseUrl}/assets/app-ABCD1234.js`)
|
||||
|
||||
expect(stableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(nestedStableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(fingerprintedAsset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
|
||||
})
|
||||
|
||||
it('creates, reads and updates navigation through the API', async () => {
|
||||
@@ -103,6 +138,21 @@ describe('HTTP server', () => {
|
||||
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
|
||||
})
|
||||
|
||||
it('keeps concurrent navigation writes valid and complete', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const first = { version: 1, categories: defaults.categories }
|
||||
const second = { version: 1, categories: [...defaults.categories].reverse() }
|
||||
const responses = await Promise.all([first, second].map((navigation) => fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(navigation),
|
||||
})))
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200])
|
||||
const stored = JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))
|
||||
expect([first, second]).toContainEqual(stored)
|
||||
})
|
||||
|
||||
it('normalizes an existing navigation file when a later release adds a tool', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }
|
||||
@@ -144,7 +194,7 @@ describe('HTTP server', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid, oversized and cross-site navigation writes', async () => {
|
||||
it('rejects invalid, oversized and cross-origin browser navigation writes', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const invalid = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
@@ -167,5 +217,19 @@ describe('HTTP server', () => {
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
|
||||
const sameSite = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameSite.status).toBe(403)
|
||||
|
||||
const sameOrigin = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameOrigin.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateXmlDocument, XML_PARSER_ERROR_NAMESPACES } from '../src/lib/xml'
|
||||
|
||||
function parserWithCandidates(candidates: Array<{ localName: string; namespaceURI: string | null; textContent: string | null }>) {
|
||||
return {
|
||||
parseFromString: () => ({
|
||||
getElementsByTagName: (localName: string) => {
|
||||
expect(localName).toBe('parsererror')
|
||||
return candidates
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('XML validation', () => {
|
||||
it('accepts valid content even when the user root element is named parsererror', () => {
|
||||
expect(() => validateXmlDocument('<parsererror>valid</parsererror>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI: null, textContent: 'valid' },
|
||||
]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports parser errors from the browser parser-error namespace', () => {
|
||||
for (const namespaceURI of XML_PARSER_ERROR_NAMESPACES) {
|
||||
expect(() => validateXmlDocument('<root>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI, textContent: 'XML parsing error\nDetails' },
|
||||
]))).toThrow('XML parsing error')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty input before invoking the parser', () => {
|
||||
expect(() => validateXmlDocument(' ', parserWithCandidates([]))).toThrow('请输入 XML')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user