mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-19 03:41:40 +08:00
- 保留 Redis JSON 值中的大整数原始字面量 - 避免自动格式化时通过 JSON.stringify 改写超出安全范围的数字 - 补充自动模式大整数与字符串转义展示回归测试 Refs #400
276 lines
7.2 KiB
TypeScript
276 lines
7.2 KiB
TypeScript
const hasDecodedUnicodeText = (value: string): boolean => {
|
|
for (let i = 0; i < value.length; i++) {
|
|
if (value.charCodeAt(i) > 0xFF) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const toByteArray = (value: string): Uint8Array => {
|
|
const bytes = new Uint8Array(value.length);
|
|
for (let i = 0; i < value.length; i++) {
|
|
bytes[i] = value.charCodeAt(i) & 0xFF;
|
|
}
|
|
|
|
return bytes;
|
|
};
|
|
|
|
const decodeUtf8Bytes = (value: string): string => (
|
|
new TextDecoder('utf-8', { fatal: false }).decode(toByteArray(value))
|
|
);
|
|
|
|
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
|
|
if (!value || value.length === 0) {
|
|
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
|
|
}
|
|
|
|
if (hasDecodedUnicodeText(value)) {
|
|
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
|
}
|
|
|
|
let nullCount = 0;
|
|
let printableCount = 0;
|
|
let highByteCount = 0;
|
|
const sampleSize = Math.min(value.length, 200);
|
|
|
|
for (let i = 0; i < sampleSize; i++) {
|
|
const code = value.charCodeAt(i);
|
|
if (code === 0) {
|
|
nullCount++;
|
|
} else if (code >= 32 && code < 127) {
|
|
printableCount++;
|
|
} else if (code >= 128) {
|
|
highByteCount++;
|
|
}
|
|
}
|
|
|
|
if (nullCount / sampleSize > 0.3) {
|
|
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
|
}
|
|
|
|
if (highByteCount === 0 && printableCount / sampleSize > 0.7) {
|
|
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
|
}
|
|
|
|
if (highByteCount > 0) {
|
|
try {
|
|
const decoded = decodeUtf8Bytes(value);
|
|
let validChars = 0;
|
|
let replacementChars = 0;
|
|
let controlChars = 0;
|
|
|
|
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
|
|
const code = decoded.charCodeAt(i);
|
|
if (code === 0xFFFD) {
|
|
replacementChars++;
|
|
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
|
controlChars++;
|
|
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
|
|
validChars++;
|
|
}
|
|
}
|
|
|
|
const totalChecked = Math.max(1, Math.min(decoded.length, 200));
|
|
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
|
|
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
|
}
|
|
|
|
if (validChars / totalChecked > 0.5) {
|
|
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
|
|
}
|
|
} catch {
|
|
// ignore decode failure
|
|
}
|
|
}
|
|
|
|
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
|
};
|
|
|
|
const findNextNonWhitespace = (value: string, startIndex: number): string => {
|
|
for (let i = startIndex; i < value.length; i++) {
|
|
if (!/\s/.test(value[i])) {
|
|
return value[i];
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const readJsonStringToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
|
|
let index = startIndex + 1;
|
|
let escaped = false;
|
|
while (index < value.length) {
|
|
const char = value[index];
|
|
if (escaped) {
|
|
escaped = false;
|
|
index++;
|
|
continue;
|
|
}
|
|
if (char === '\\') {
|
|
escaped = true;
|
|
index++;
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
return { token: value.slice(startIndex, index + 1), nextIndex: index + 1 };
|
|
}
|
|
index++;
|
|
}
|
|
return { token: value.slice(startIndex), nextIndex: value.length };
|
|
};
|
|
|
|
const readJsonPrimitiveToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
|
|
let index = startIndex;
|
|
while (index < value.length && !/[\s,\]}]/.test(value[index])) {
|
|
index++;
|
|
}
|
|
return { token: value.slice(startIndex, index), nextIndex: index };
|
|
};
|
|
|
|
const formatJsonStringToken = (token: string): string => {
|
|
try {
|
|
return JSON.stringify(JSON.parse(token));
|
|
} catch {
|
|
return token;
|
|
}
|
|
};
|
|
|
|
const formatJsonPreservingNumberLiterals = (value: string): string | null => {
|
|
try {
|
|
JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const indentUnit = ' ';
|
|
const indent = (depth: number) => indentUnit.repeat(Math.max(0, depth));
|
|
let result = '';
|
|
let depth = 0;
|
|
let index = 0;
|
|
let lastToken: 'open' | 'value' | 'close' | 'comma' | 'colon' | '' = '';
|
|
|
|
while (index < value.length) {
|
|
const char = value[index];
|
|
if (/\s/.test(char)) {
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
const { token, nextIndex } = readJsonStringToken(value, index);
|
|
result += formatJsonStringToken(token);
|
|
lastToken = 'value';
|
|
index = nextIndex;
|
|
continue;
|
|
}
|
|
|
|
if (char === '{' || char === '[') {
|
|
const closeChar = char === '{' ? '}' : ']';
|
|
result += char;
|
|
depth++;
|
|
lastToken = 'open';
|
|
if (findNextNonWhitespace(value, index + 1) !== closeChar) {
|
|
result += `\n${indent(depth)}`;
|
|
}
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (char === '}' || char === ']') {
|
|
depth--;
|
|
if (lastToken !== 'open') {
|
|
result += `\n${indent(depth)}`;
|
|
}
|
|
result += char;
|
|
lastToken = 'close';
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (char === ',') {
|
|
result += `,\n${indent(depth)}`;
|
|
lastToken = 'comma';
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (char === ':') {
|
|
result += ': ';
|
|
lastToken = 'colon';
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
const { token, nextIndex } = readJsonPrimitiveToken(value, index);
|
|
result += token;
|
|
lastToken = 'value';
|
|
index = nextIndex;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
|
|
const formatted = formatJsonPreservingNumberLiterals(value);
|
|
if (formatted !== null) {
|
|
return { isJson: true, formatted };
|
|
}
|
|
return { isJson: false, formatted: value };
|
|
};
|
|
|
|
export const toHexDisplay = (value: string): string => {
|
|
const bytes: string[] = [];
|
|
const ascii: string[] = [];
|
|
let result = '';
|
|
|
|
for (let i = 0; i < value.length; i++) {
|
|
const code = value.charCodeAt(i);
|
|
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
|
|
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
|
|
|
|
if (bytes.length === 16 || i === value.length - 1) {
|
|
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
|
|
const hexPart = bytes.join(' ').padEnd(47, ' ');
|
|
const asciiPart = ascii.join('');
|
|
result += `${offset} ${hexPart} |${asciiPart}|\n`;
|
|
bytes.length = 0;
|
|
ascii.length = 0;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const decodeRedisUtf8Value = (value: string): string => {
|
|
if (!value || value.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
if (hasDecodedUnicodeText(value)) {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
for (let i = 0; i < value.length; i++) {
|
|
if (value.charCodeAt(i) > 0x7F) {
|
|
return decodeUtf8Bytes(value);
|
|
}
|
|
}
|
|
|
|
return value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
export const formatRedisStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding: string } => {
|
|
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
|
|
if (needsHex) {
|
|
return { displayValue, isBinary: true, isJson: false, encoding };
|
|
}
|
|
|
|
const { isJson, formatted } = tryFormatJson(displayValue);
|
|
return { displayValue: formatted, isBinary: false, isJson, encoding };
|
|
};
|