@ddunigma/node 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENCE +1 -1
- package/README.md +253 -79
- package/dist/cjs/encoders/Ddu64.d.ts +118 -1
- package/dist/cjs/encoders/Ddu64.js +521 -154
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.js +8 -1
- package/dist/cjs/types/DduInterface.d.ts +53 -1
- package/dist/cjs/utils/CharsetBuilder.d.ts +129 -0
- package/dist/cjs/utils/CharsetBuilder.js +246 -0
- package/dist/cjs/utils/DduPipeline.d.ts +114 -0
- package/dist/cjs/utils/DduPipeline.js +229 -0
- package/dist/cjs/utils/DduStream.d.ts +64 -0
- package/dist/cjs/utils/DduStream.js +221 -0
- package/dist/cjs/utils/crypto.d.ts +30 -0
- package/dist/cjs/utils/crypto.js +84 -0
- package/dist/cjs/utils/index.d.ts +3 -0
- package/dist/cjs/utils/index.js +12 -0
- package/dist/mjs/encoders/Ddu64.d.ts +118 -1
- package/dist/mjs/encoders/Ddu64.js +534 -155
- package/dist/mjs/index.d.ts +2 -1
- package/dist/mjs/index.js +1 -0
- package/dist/mjs/types/DduInterface.d.ts +53 -1
- package/dist/mjs/utils/CharsetBuilder.d.ts +129 -0
- package/dist/mjs/utils/CharsetBuilder.js +241 -0
- package/dist/mjs/utils/DduPipeline.d.ts +114 -0
- package/dist/mjs/utils/DduPipeline.js +223 -0
- package/dist/mjs/utils/DduStream.d.ts +64 -0
- package/dist/mjs/utils/DduStream.js +225 -0
- package/dist/mjs/utils/crypto.d.ts +30 -0
- package/dist/mjs/utils/crypto.js +78 -0
- package/dist/mjs/utils/index.d.ts +3 -0
- package/dist/mjs/utils/index.js +3 -0
- package/package.json +4 -3
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { deflateSync
|
|
1
|
+
import { deflateSync } from "zlib";
|
|
2
2
|
import { BaseDdu } from "../base/BaseDdu.js";
|
|
3
3
|
import { DduSetSymbol, dduDefaultConstructorOptions, } from "../types/index.js";
|
|
4
4
|
import { getCharSet } from "../charSets/index.js";
|
|
5
|
+
import { deriveKey, encryptAes256Gcm, decryptAes256Gcm, inflateWithLimit as inflateWithLimitUtil, } from "../utils/crypto.js";
|
|
5
6
|
// ============================================================================
|
|
6
7
|
// 상수 정의
|
|
7
8
|
// ============================================================================
|
|
@@ -13,10 +14,56 @@ const MAX_FAST_BITS = 16;
|
|
|
13
14
|
const BYTE_MASK = 0xff;
|
|
14
15
|
/** 압축 데이터 식별 마커 */
|
|
15
16
|
const COMPRESS_MARKER = "ELYSIA";
|
|
17
|
+
/** 체크섬 마커 */
|
|
18
|
+
const CHECKSUM_MARKER = "CHK";
|
|
19
|
+
/** 암호화 마커 */
|
|
20
|
+
const ENCRYPT_MARKER = "ENC";
|
|
16
21
|
/** 기본 최대 디코딩 바이트 수 (64MB) */
|
|
17
22
|
const DEFAULT_MAX_DECODED_BYTES = 64 * 1024 * 1024;
|
|
18
23
|
/** 기본 최대 압축해제 바이트 수 (64MB) */
|
|
19
24
|
const DEFAULT_MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
/** CRC32 룩업 테이블 (바이트 단위 연산으로 비트 루프 대비 4~8배 빠름) */
|
|
26
|
+
const CRC32_TABLE = (() => {
|
|
27
|
+
const table = new Uint32Array(256);
|
|
28
|
+
for (let i = 0; i < 256; i++) {
|
|
29
|
+
let crc = i;
|
|
30
|
+
for (let j = 0; j < 8; j++) {
|
|
31
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
32
|
+
}
|
|
33
|
+
table[i] = crc;
|
|
34
|
+
}
|
|
35
|
+
return table;
|
|
36
|
+
})();
|
|
37
|
+
/** URL-Safe 문자 매핑 */
|
|
38
|
+
const URL_SAFE_MAP = {
|
|
39
|
+
"+": "-",
|
|
40
|
+
"/": "_",
|
|
41
|
+
"=": ".",
|
|
42
|
+
};
|
|
43
|
+
/** URL-Safe 역방향 매핑 */
|
|
44
|
+
const URL_SAFE_REVERSE_MAP = {
|
|
45
|
+
"-": "+",
|
|
46
|
+
"_": "/",
|
|
47
|
+
".": "=",
|
|
48
|
+
};
|
|
49
|
+
/** 인코딩 진행률 단계별 퍼센트 */
|
|
50
|
+
const ENCODE_PROGRESS = {
|
|
51
|
+
START: 0,
|
|
52
|
+
ENCRYPT: 15,
|
|
53
|
+
CHECKSUM: 20,
|
|
54
|
+
COMPRESS: 40,
|
|
55
|
+
ENCODE: 50,
|
|
56
|
+
DONE: 100,
|
|
57
|
+
};
|
|
58
|
+
/** 디코딩 진행률 단계별 퍼센트 */
|
|
59
|
+
const DECODE_PROGRESS = {
|
|
60
|
+
START: 0,
|
|
61
|
+
DECODE: 20,
|
|
62
|
+
DECOMPRESS: 50,
|
|
63
|
+
CHECKSUM: 70,
|
|
64
|
+
DECRYPT: 85,
|
|
65
|
+
DONE: 100,
|
|
66
|
+
};
|
|
20
67
|
// ============================================================================
|
|
21
68
|
// Ddu64 클래스
|
|
22
69
|
// ============================================================================
|
|
@@ -73,6 +120,18 @@ export class Ddu64 extends BaseDdu {
|
|
|
73
120
|
fastAsciiLookup = null;
|
|
74
121
|
/** ASCII 룩업 사용 여부 */
|
|
75
122
|
useAsciiLookup = false;
|
|
123
|
+
/** URL-Safe 모드 여부 */
|
|
124
|
+
urlSafe;
|
|
125
|
+
/** 암호화 키 해시 (AES-256용 32바이트) */
|
|
126
|
+
encryptionKeyHash;
|
|
127
|
+
/** 기본 체크섬 사용 여부 */
|
|
128
|
+
defaultChecksum;
|
|
129
|
+
/** 기본 청크 크기 */
|
|
130
|
+
defaultChunkSize;
|
|
131
|
+
/** 기본 청크 구분자 */
|
|
132
|
+
defaultChunkSeparator;
|
|
133
|
+
/** 기본 압축 레벨 (1~9) */
|
|
134
|
+
defaultCompressionLevel;
|
|
76
135
|
// --------------------------------------------------------------------------
|
|
77
136
|
// 생성자
|
|
78
137
|
// --------------------------------------------------------------------------
|
|
@@ -96,7 +155,8 @@ export class Ddu64 extends BaseDdu {
|
|
|
96
155
|
*/
|
|
97
156
|
constructor(dduChar, paddingChar, dduOptions) {
|
|
98
157
|
super();
|
|
99
|
-
|
|
158
|
+
// throwOnError 우선, useBuildErrorReturn은 하위 호환
|
|
159
|
+
const shouldThrow = dduOptions?.throwOnError ?? dduOptions?.useBuildErrorReturn ?? false;
|
|
100
160
|
// charset 초기화
|
|
101
161
|
const initial = this.resolveInitialCharSet(dduChar, paddingChar, dduOptions, shouldThrow);
|
|
102
162
|
const normalized = this.normalizeCharSet(initial, shouldThrow, dduOptions);
|
|
@@ -149,6 +209,18 @@ export class Ddu64 extends BaseDdu {
|
|
|
149
209
|
if (this.charLength === 1 && !this.isPredefinedCharSet) {
|
|
150
210
|
this.validateCombinationDuplicates(this.dduChar, this.paddingChar, dduLength);
|
|
151
211
|
}
|
|
212
|
+
// 새로운 옵션들 초기화
|
|
213
|
+
const requestUrlSafe = dduOptions?.urlSafe ?? false;
|
|
214
|
+
this.urlSafe = requestUrlSafe
|
|
215
|
+
? this.isUrlSafeCompatible(this.dduChar, this.paddingChar, shouldThrow)
|
|
216
|
+
: false;
|
|
217
|
+
this.encryptionKeyHash = dduOptions?.encryptionKey
|
|
218
|
+
? deriveKey(dduOptions.encryptionKey)
|
|
219
|
+
: undefined;
|
|
220
|
+
this.defaultChecksum = dduOptions?.checksum ?? false;
|
|
221
|
+
this.defaultChunkSize = dduOptions?.chunkSize;
|
|
222
|
+
this.defaultChunkSeparator = dduOptions?.chunkSeparator ?? "\n";
|
|
223
|
+
this.defaultCompressionLevel = Math.min(9, Math.max(1, Math.floor(dduOptions?.compressionLevel ?? 6)));
|
|
152
224
|
}
|
|
153
225
|
// --------------------------------------------------------------------------
|
|
154
226
|
// 공개 메서드
|
|
@@ -159,32 +231,92 @@ export class Ddu64 extends BaseDdu {
|
|
|
159
231
|
* @param input - 인코딩할 문자열 또는 Buffer
|
|
160
232
|
* @param options - 인코딩 옵션
|
|
161
233
|
* @param options.compress - 압축 사용 여부 (기본값: 생성자 설정)
|
|
234
|
+
* @param options.checksum - 체크섬 추가 여부
|
|
235
|
+
* @param options.chunkSize - 청크 분할 크기
|
|
236
|
+
* @param options.chunkSeparator - 청크 구분자
|
|
237
|
+
* @param options.onProgress - 진행률 콜백
|
|
162
238
|
* @returns 인코딩된 문자열
|
|
163
239
|
*
|
|
164
240
|
* @example
|
|
165
241
|
* encoder.encode("Hello World!");
|
|
166
242
|
* encoder.encode(buffer, { compress: true });
|
|
243
|
+
* encoder.encode(data, { checksum: true, chunkSize: 76 });
|
|
167
244
|
*/
|
|
168
245
|
encode(input, options) {
|
|
246
|
+
return this.encodeInternal(input, options).encoded;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 인코딩 핵심 로직. encode()와 getStats()가 공유합니다.
|
|
250
|
+
* 압축 크기 등 메타데이터도 함께 반환하여 중복 deflateSync 호출을 방지합니다.
|
|
251
|
+
*/
|
|
252
|
+
encodeInternal(input, options) {
|
|
169
253
|
const shouldCompress = options?.compress ?? this.defaultCompress;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
254
|
+
const shouldChecksum = options?.checksum ?? this.defaultChecksum;
|
|
255
|
+
const chunkSize = options?.chunkSize ?? this.defaultChunkSize;
|
|
256
|
+
const chunkSeparator = options?.chunkSeparator ?? this.defaultChunkSeparator;
|
|
257
|
+
const onProgress = options?.onProgress;
|
|
258
|
+
let workingBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
|
|
259
|
+
const totalBytes = workingBuffer.length;
|
|
260
|
+
// 진행률 콜백 호출 (시작)
|
|
261
|
+
if (onProgress) {
|
|
262
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.START, stage: "start" });
|
|
263
|
+
}
|
|
264
|
+
// 암호화 처리
|
|
265
|
+
let isEncrypted = false;
|
|
266
|
+
if (this.encryptionKeyHash) {
|
|
267
|
+
workingBuffer = this.encryptData(workingBuffer);
|
|
268
|
+
isEncrypted = true;
|
|
269
|
+
if (onProgress) {
|
|
270
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.ENCRYPT, stage: "encrypt" });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// 체크섬 계산
|
|
274
|
+
let checksum = "";
|
|
275
|
+
if (shouldChecksum) {
|
|
276
|
+
checksum = this.calculateCRC32(workingBuffer);
|
|
277
|
+
if (onProgress) {
|
|
278
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.CHECKSUM, stage: "checksum" });
|
|
279
|
+
}
|
|
176
280
|
}
|
|
177
|
-
// 압축
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
281
|
+
// 압축 처리
|
|
282
|
+
let useCompression = false;
|
|
283
|
+
let compressedSize;
|
|
284
|
+
if (shouldCompress) {
|
|
285
|
+
const level = options?.compressionLevel ?? this.defaultCompressionLevel;
|
|
286
|
+
const compressedBuffer = deflateSync(workingBuffer, { level: Math.min(9, Math.max(1, level)) });
|
|
287
|
+
compressedSize = compressedBuffer.length;
|
|
288
|
+
if (compressedBuffer.length < workingBuffer.length) {
|
|
289
|
+
workingBuffer = compressedBuffer;
|
|
290
|
+
useCompression = true;
|
|
291
|
+
}
|
|
292
|
+
if (onProgress) {
|
|
293
|
+
onProgress({ processedBytes: Math.floor(totalBytes * 0.4), totalBytes, percent: ENCODE_PROGRESS.COMPRESS, stage: "compress" });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// 인코딩 수행
|
|
297
|
+
if (onProgress) {
|
|
298
|
+
onProgress({ processedBytes: Math.floor(totalBytes * 0.5), totalBytes, percent: ENCODE_PROGRESS.ENCODE, stage: "encode" });
|
|
299
|
+
}
|
|
300
|
+
let result = this.effectiveBitLength <= MAX_FAST_BITS
|
|
301
|
+
? this.encodeFast(workingBuffer, useCompression, isEncrypted)
|
|
302
|
+
: this.encodeBigInt(workingBuffer, useCompression, isEncrypted);
|
|
303
|
+
// 체크섬 추가
|
|
304
|
+
if (shouldChecksum && checksum) {
|
|
305
|
+
result = result + CHECKSUM_MARKER + checksum;
|
|
306
|
+
}
|
|
307
|
+
// URL-Safe 변환
|
|
308
|
+
if (this.urlSafe) {
|
|
309
|
+
result = this.toUrlSafe(result);
|
|
184
310
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
311
|
+
// 청크 분할
|
|
312
|
+
if (chunkSize && chunkSize > 0) {
|
|
313
|
+
result = this.splitIntoChunks(result, chunkSize, chunkSeparator);
|
|
314
|
+
}
|
|
315
|
+
// 진행률 콜백 호출 (완료)
|
|
316
|
+
if (onProgress) {
|
|
317
|
+
onProgress({ processedBytes: totalBytes, totalBytes, percent: ENCODE_PROGRESS.DONE, stage: "done" });
|
|
318
|
+
}
|
|
319
|
+
return { encoded: result, compressedSize };
|
|
188
320
|
}
|
|
189
321
|
/**
|
|
190
322
|
* 인코딩된 문자열을 Buffer로 디코딩합니다.
|
|
@@ -193,14 +325,37 @@ export class Ddu64 extends BaseDdu {
|
|
|
193
325
|
* @param options - 디코딩 옵션
|
|
194
326
|
* @param options.maxDecodedBytes - 최대 디코딩 바이트 수
|
|
195
327
|
* @param options.maxDecompressedBytes - 최대 압축해제 바이트 수
|
|
328
|
+
* @param options.onProgress - 진행률 콜백
|
|
196
329
|
* @returns 디코딩된 Buffer
|
|
197
330
|
*
|
|
198
331
|
* @throws 잘못된 문자가 포함된 경우
|
|
199
332
|
* @throws 패딩 형식이 잘못된 경우
|
|
200
333
|
* @throws 크기 제한 초과 시
|
|
334
|
+
* @throws 체크섬 불일치 시
|
|
201
335
|
*/
|
|
202
336
|
decodeToBuffer(input, options) {
|
|
203
|
-
const
|
|
337
|
+
const shouldChecksum = options?.checksum ?? this.defaultChecksum;
|
|
338
|
+
const onProgress = options?.onProgress;
|
|
339
|
+
let workingInput = input;
|
|
340
|
+
const inputLength = input.length;
|
|
341
|
+
// 진행률 콜백 호출 (시작)
|
|
342
|
+
if (onProgress) {
|
|
343
|
+
onProgress({ processedBytes: 0, totalBytes: inputLength, percent: DECODE_PROGRESS.START, stage: "start" });
|
|
344
|
+
}
|
|
345
|
+
// 청크 제거 (줄바꿈 등 구분자 제거)
|
|
346
|
+
workingInput = this.removeChunks(workingInput);
|
|
347
|
+
// URL-Safe 역변환
|
|
348
|
+
if (this.urlSafe) {
|
|
349
|
+
workingInput = this.fromUrlSafe(workingInput);
|
|
350
|
+
}
|
|
351
|
+
// 체크섬 추출 (체크섬이 활성화된 경우에만 수행하여 오탐지 방지)
|
|
352
|
+
let extractedChecksum = null;
|
|
353
|
+
if (shouldChecksum) {
|
|
354
|
+
const result = this.extractChecksum(workingInput);
|
|
355
|
+
extractedChecksum = result.checksum;
|
|
356
|
+
workingInput = result.data;
|
|
357
|
+
}
|
|
358
|
+
const { cleanedInput, paddingBits, isCompressed, isEncrypted } = this.parseFooter(workingInput);
|
|
204
359
|
this.assertEncodedInputAligned(cleanedInput);
|
|
205
360
|
// 디코딩 크기 검증
|
|
206
361
|
const maxDecodedBytes = this.normalizeLimit(options?.maxDecodedBytes, this.defaultMaxDecodedBytes, true, "maxDecodedBytes");
|
|
@@ -209,14 +364,42 @@ export class Ddu64 extends BaseDdu {
|
|
|
209
364
|
throw new Error(`[Ddu64 decode] Decoded output exceeds limit. Estimated: ${estimatedDecodedBytes} bytes, Limit: ${maxDecodedBytes} bytes`);
|
|
210
365
|
}
|
|
211
366
|
// 디코딩 수행
|
|
212
|
-
|
|
367
|
+
if (onProgress) {
|
|
368
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.2), totalBytes: inputLength, percent: DECODE_PROGRESS.DECODE, stage: "decode" });
|
|
369
|
+
}
|
|
370
|
+
let decoded = this.effectiveBitLength <= MAX_FAST_BITS
|
|
213
371
|
? this.decodeFast(cleanedInput, paddingBits)
|
|
214
372
|
: this.decodeBigInt(cleanedInput, paddingBits);
|
|
215
|
-
if (!isCompressed)
|
|
216
|
-
return decoded;
|
|
217
373
|
// 압축 해제
|
|
218
|
-
|
|
219
|
-
|
|
374
|
+
if (isCompressed) {
|
|
375
|
+
if (onProgress) {
|
|
376
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.5), totalBytes: inputLength, percent: DECODE_PROGRESS.DECOMPRESS, stage: "decompress" });
|
|
377
|
+
}
|
|
378
|
+
const maxDecompressedBytes = this.normalizeLimit(options?.maxDecompressedBytes, this.defaultMaxDecompressedBytes, true, "maxDecompressedBytes");
|
|
379
|
+
decoded = this.inflateWithLimit(decoded, maxDecompressedBytes);
|
|
380
|
+
}
|
|
381
|
+
// 체크섬 검증
|
|
382
|
+
if (extractedChecksum) {
|
|
383
|
+
if (onProgress) {
|
|
384
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.7), totalBytes: inputLength, percent: DECODE_PROGRESS.CHECKSUM, stage: "checksum" });
|
|
385
|
+
}
|
|
386
|
+
const calculatedChecksum = this.calculateCRC32(decoded);
|
|
387
|
+
if (calculatedChecksum !== extractedChecksum) {
|
|
388
|
+
throw new Error(`[Ddu64 decode] Checksum mismatch. Expected: ${extractedChecksum}, Got: ${calculatedChecksum}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// 복호화
|
|
392
|
+
if (isEncrypted && this.encryptionKeyHash) {
|
|
393
|
+
if (onProgress) {
|
|
394
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.85), totalBytes: inputLength, percent: DECODE_PROGRESS.DECRYPT, stage: "decrypt" });
|
|
395
|
+
}
|
|
396
|
+
decoded = this.decryptData(decoded);
|
|
397
|
+
}
|
|
398
|
+
// 진행률 콜백 호출 (완료)
|
|
399
|
+
if (onProgress) {
|
|
400
|
+
onProgress({ processedBytes: inputLength, totalBytes: inputLength, percent: DECODE_PROGRESS.DONE, stage: "done" });
|
|
401
|
+
}
|
|
402
|
+
return decoded;
|
|
220
403
|
}
|
|
221
404
|
/**
|
|
222
405
|
* 인코딩된 문자열을 원본 문자열로 디코딩합니다.
|
|
@@ -246,8 +429,205 @@ export class Ddu64 extends BaseDdu {
|
|
|
246
429
|
defaultCompress: this.defaultCompress,
|
|
247
430
|
defaultMaxDecodedBytes: this.defaultMaxDecodedBytes,
|
|
248
431
|
defaultMaxDecompressedBytes: this.defaultMaxDecompressedBytes,
|
|
432
|
+
urlSafe: this.urlSafe,
|
|
433
|
+
hasEncryptionKey: !!this.encryptionKeyHash,
|
|
434
|
+
defaultChecksum: this.defaultChecksum,
|
|
435
|
+
defaultChunkSize: this.defaultChunkSize,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* 인코딩 통계 정보를 반환합니다.
|
|
440
|
+
*
|
|
441
|
+
* @param input - 분석할 데이터
|
|
442
|
+
* @param options - 인코딩 옵션
|
|
443
|
+
* @returns 통계 정보 객체
|
|
444
|
+
*/
|
|
445
|
+
getStats(input, options) {
|
|
446
|
+
const shouldCompress = options?.compress ?? this.defaultCompress;
|
|
447
|
+
const originalBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
|
|
448
|
+
const originalSize = originalBuffer.length;
|
|
449
|
+
// encodeInternal을 통해 인코딩과 압축 크기를 한 번에 얻어 중복 deflateSync 방지
|
|
450
|
+
const { encoded, compressedSize } = this.encodeInternal(input, options);
|
|
451
|
+
const encodedSize = encoded.length;
|
|
452
|
+
const expansionRatio = originalSize > 0 ? encodedSize / originalSize : 0;
|
|
453
|
+
const compressionRatio = shouldCompress && compressedSize !== undefined && originalSize > 0
|
|
454
|
+
? compressedSize / originalSize
|
|
455
|
+
: undefined;
|
|
456
|
+
return {
|
|
457
|
+
originalSize,
|
|
458
|
+
encodedSize,
|
|
459
|
+
compressedSize,
|
|
460
|
+
compressionRatio,
|
|
461
|
+
expansionRatio,
|
|
462
|
+
charsetSize: this.dduChar.length,
|
|
463
|
+
bitLength: this.bitLength,
|
|
249
464
|
};
|
|
250
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 비동기 인코딩을 수행합니다.
|
|
468
|
+
*
|
|
469
|
+
* 내부적으로 동기 encode()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
470
|
+
* 호출자가 즉시 블로킹되지 않도록 보장하지만, 인코딩 자체는 단일 동기 작업으로
|
|
471
|
+
* 수행되므로 대용량 데이터(수 MB 이상) 처리 시 이벤트 루프가 블로킹될 수 있습니다.
|
|
472
|
+
* 대용량 처리가 필요한 경우 스트림 API(createEncodeStream) 사용을 권장합니다.
|
|
473
|
+
*
|
|
474
|
+
* @param input - 인코딩할 데이터
|
|
475
|
+
* @param options - 인코딩 옵션
|
|
476
|
+
* @returns 인코딩된 문자열 Promise
|
|
477
|
+
*/
|
|
478
|
+
async encodeAsync(input, options) {
|
|
479
|
+
return new Promise((resolve, reject) => {
|
|
480
|
+
setImmediate(() => {
|
|
481
|
+
try {
|
|
482
|
+
resolve(this.encode(input, options));
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
reject(e);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* 비동기 디코딩을 수행합니다.
|
|
492
|
+
*
|
|
493
|
+
* 내부적으로 동기 decode()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
494
|
+
* 대용량 처리가 필요한 경우 스트림 API(createDecodeStream) 사용을 권장합니다.
|
|
495
|
+
*
|
|
496
|
+
* @param input - 디코딩할 인코딩된 문자열
|
|
497
|
+
* @param options - 디코딩 옵션
|
|
498
|
+
* @returns 디코딩된 문자열 Promise
|
|
499
|
+
*/
|
|
500
|
+
async decodeAsync(input, options) {
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
setImmediate(() => {
|
|
503
|
+
try {
|
|
504
|
+
resolve(this.decode(input, options));
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
reject(e);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* 비동기 디코딩을 Buffer로 수행합니다.
|
|
514
|
+
*
|
|
515
|
+
* 내부적으로 동기 decodeToBuffer()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
516
|
+
* 대용량 처리가 필요한 경우 스트림 API(createDecodeStream) 사용을 권장합니다.
|
|
517
|
+
*
|
|
518
|
+
* @param input - 디코딩할 인코딩된 문자열
|
|
519
|
+
* @param options - 디코딩 옵션
|
|
520
|
+
* @returns 디코딩된 Buffer Promise
|
|
521
|
+
*/
|
|
522
|
+
async decodeToBufferAsync(input, options) {
|
|
523
|
+
return new Promise((resolve, reject) => {
|
|
524
|
+
setImmediate(() => {
|
|
525
|
+
try {
|
|
526
|
+
resolve(this.decodeToBuffer(input, options));
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
reject(e);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
// --------------------------------------------------------------------------
|
|
535
|
+
// URL-Safe 메서드
|
|
536
|
+
// --------------------------------------------------------------------------
|
|
537
|
+
/**
|
|
538
|
+
* 문자열을 URL-Safe 형식으로 변환합니다.
|
|
539
|
+
* 단일 정규식 패스로 처리하여 split/join 3회 반복 대비 메모리/속도 개선
|
|
540
|
+
*/
|
|
541
|
+
toUrlSafe(input) {
|
|
542
|
+
return input.replace(/[+/=]/g, (c) => URL_SAFE_MAP[c]);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* URL-Safe 형식에서 원래 형식으로 복원합니다.
|
|
546
|
+
*/
|
|
547
|
+
fromUrlSafe(input) {
|
|
548
|
+
return input.replace(/[-_.]/g, (c) => URL_SAFE_REVERSE_MAP[c]);
|
|
549
|
+
}
|
|
550
|
+
// --------------------------------------------------------------------------
|
|
551
|
+
// 체크섬 메서드
|
|
552
|
+
// --------------------------------------------------------------------------
|
|
553
|
+
/**
|
|
554
|
+
* CRC32 체크섬을 계산합니다. (룩업 테이블 사용)
|
|
555
|
+
*/
|
|
556
|
+
calculateCRC32(data) {
|
|
557
|
+
let crc = 0xffffffff;
|
|
558
|
+
for (let i = 0; i < data.length; i++) {
|
|
559
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xff];
|
|
560
|
+
}
|
|
561
|
+
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, "0");
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* 인코딩된 문자열에서 체크섬을 추출합니다.
|
|
565
|
+
*/
|
|
566
|
+
extractChecksum(input) {
|
|
567
|
+
const markerIndex = input.lastIndexOf(CHECKSUM_MARKER);
|
|
568
|
+
if (markerIndex === -1) {
|
|
569
|
+
return { data: input, checksum: null };
|
|
570
|
+
}
|
|
571
|
+
const checksum = input.slice(markerIndex + CHECKSUM_MARKER.length);
|
|
572
|
+
if (checksum.length !== 8 || !/^[0-9a-f]+$/i.test(checksum)) {
|
|
573
|
+
return { data: input, checksum: null };
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
data: input.slice(0, markerIndex),
|
|
577
|
+
checksum: checksum.toLowerCase(),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// --------------------------------------------------------------------------
|
|
581
|
+
// 청크 분할 메서드
|
|
582
|
+
// --------------------------------------------------------------------------
|
|
583
|
+
/**
|
|
584
|
+
* 문자열을 청크로 분할합니다.
|
|
585
|
+
*/
|
|
586
|
+
splitIntoChunks(input, chunkSize, separator) {
|
|
587
|
+
if (chunkSize <= 0)
|
|
588
|
+
return input;
|
|
589
|
+
const chunks = [];
|
|
590
|
+
for (let i = 0; i < input.length; i += chunkSize) {
|
|
591
|
+
chunks.push(input.slice(i, i + chunkSize));
|
|
592
|
+
}
|
|
593
|
+
return chunks.join(separator);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* 청크 구분자를 제거합니다.
|
|
597
|
+
* 줄바꿈(\r, \n)과 인스턴스에 설정된 청크 구분자만 제거합니다.
|
|
598
|
+
* 공백/탭 등은 charset에 포함될 수 있으므로 제거하지 않습니다.
|
|
599
|
+
*/
|
|
600
|
+
removeChunks(input) {
|
|
601
|
+
// 줄바꿈 문자(\r, \n)는 항상 제거 (기본 구분자 및 일반적 라인 구분)
|
|
602
|
+
let result = input.replace(/[\r\n]/g, "");
|
|
603
|
+
// 커스텀 구분자가 줄바꿈이 아닌 경우 추가로 제거
|
|
604
|
+
const sep = this.defaultChunkSeparator;
|
|
605
|
+
if (sep && sep !== "\n" && sep !== "\r\n" && sep !== "\r") {
|
|
606
|
+
result = result.split(sep).join("");
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
// --------------------------------------------------------------------------
|
|
611
|
+
// 암호화 메서드
|
|
612
|
+
// --------------------------------------------------------------------------
|
|
613
|
+
/**
|
|
614
|
+
* 데이터를 AES-256-GCM으로 암호화합니다.
|
|
615
|
+
*/
|
|
616
|
+
encryptData(data) {
|
|
617
|
+
if (!this.encryptionKeyHash) {
|
|
618
|
+
throw new Error("[Ddu64 encrypt] Encryption key is not set");
|
|
619
|
+
}
|
|
620
|
+
return encryptAes256Gcm(data, this.encryptionKeyHash);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* AES-256-GCM으로 암호화된 데이터를 복호화합니다.
|
|
624
|
+
*/
|
|
625
|
+
decryptData(data) {
|
|
626
|
+
if (!this.encryptionKeyHash) {
|
|
627
|
+
throw new Error("[Ddu64 decrypt] Encryption key is not set");
|
|
628
|
+
}
|
|
629
|
+
return decryptAes256Gcm(data, this.encryptionKeyHash);
|
|
630
|
+
}
|
|
251
631
|
// --------------------------------------------------------------------------
|
|
252
632
|
// 유틸리티 메서드
|
|
253
633
|
// --------------------------------------------------------------------------
|
|
@@ -304,143 +684,85 @@ export class Ddu64 extends BaseDdu {
|
|
|
304
684
|
* 크기 제한을 적용하여 압축을 해제합니다.
|
|
305
685
|
*/
|
|
306
686
|
inflateWithLimit(data, maxBytes) {
|
|
307
|
-
|
|
308
|
-
return inflateSync(data);
|
|
309
|
-
try {
|
|
310
|
-
return inflateSync(data, { maxOutputLength: maxBytes });
|
|
311
|
-
}
|
|
312
|
-
catch (e) {
|
|
313
|
-
const msg = String(e?.message ?? "");
|
|
314
|
-
const code = String(e?.code ?? "");
|
|
315
|
-
// maxOutputLength 미지원 시 fallback
|
|
316
|
-
if (msg.toLowerCase().includes("maxoutputlength") ||
|
|
317
|
-
msg.toLowerCase().includes("unknown option") ||
|
|
318
|
-
code === "ERR_INVALID_ARG_VALUE") {
|
|
319
|
-
const inflated = inflateSync(data);
|
|
320
|
-
if (inflated.length > maxBytes) {
|
|
321
|
-
throw new Error(`[Ddu64 decode] Decompressed data exceeds limit. Size: ${inflated.length} bytes, Limit: ${maxBytes} bytes`);
|
|
322
|
-
}
|
|
323
|
-
return inflated;
|
|
324
|
-
}
|
|
325
|
-
// 출력 제한 초과
|
|
326
|
-
if (code === "ERR_BUFFER_TOO_LARGE" ||
|
|
327
|
-
msg.toLowerCase().includes("output length") ||
|
|
328
|
-
msg.toLowerCase().includes("buffer too large")) {
|
|
329
|
-
throw new Error(`[Ddu64 decode] Decompressed data exceeds limit. Limit: ${maxBytes} bytes`);
|
|
330
|
-
}
|
|
331
|
-
throw e;
|
|
332
|
-
}
|
|
687
|
+
return inflateWithLimitUtil(data, maxBytes, "Ddu64 decode");
|
|
333
688
|
}
|
|
334
689
|
// --------------------------------------------------------------------------
|
|
335
690
|
// 푸터 파싱
|
|
336
691
|
// --------------------------------------------------------------------------
|
|
337
692
|
/**
|
|
338
693
|
* 인코딩된 문자열의 푸터(패딩 정보)를 파싱합니다.
|
|
694
|
+
*
|
|
695
|
+
* 푸터 형식: {encodedData}{paddingChar}[ELYSIA][ENC]{digits}
|
|
696
|
+
* 끝에서부터 역순으로 파싱하여 paddingChar가 숫자인 경우도 안전하게 처리합니다.
|
|
339
697
|
*/
|
|
340
698
|
parseFooter(input) {
|
|
341
699
|
const inputLen = input.length;
|
|
342
700
|
const pad = this.paddingChar;
|
|
343
701
|
const padLen = pad.length;
|
|
702
|
+
const noFooter = { cleanedInput: input, paddingBits: 0, isCompressed: false, isEncrypted: false };
|
|
344
703
|
if (inputLen < padLen)
|
|
345
|
-
return
|
|
346
|
-
const markerLen = COMPRESS_MARKER.length;
|
|
704
|
+
return noFooter;
|
|
347
705
|
const maxPaddingBits = Math.max(0, this.effectiveBitLength - 1);
|
|
348
706
|
const maxDigits = maxPaddingBits.toString().length;
|
|
349
|
-
//
|
|
350
|
-
const matchesAt = (str, pattern, start) => {
|
|
351
|
-
const pLen = pattern.length;
|
|
352
|
-
if (start < 0 || start + pLen > str.length)
|
|
353
|
-
return false;
|
|
354
|
-
for (let i = 0; i < pLen; i++) {
|
|
355
|
-
if (str.charCodeAt(start + i) !== pattern.charCodeAt(i))
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
return true;
|
|
359
|
-
};
|
|
360
|
-
const isDigitCode = (code) => code >= 48 && code <= 57;
|
|
361
|
-
// 푸터 패턴 탐색
|
|
707
|
+
// 끝에서부터 역순 파싱: digits → ENC → ELYSIA → paddingChar
|
|
362
708
|
for (let digitCount = Math.min(maxDigits, inputLen); digitCount >= 1; digitCount--) {
|
|
363
709
|
const digitsStart = inputLen - digitCount;
|
|
364
|
-
|
|
365
|
-
if (!isDigitCode(firstCode))
|
|
366
|
-
continue;
|
|
710
|
+
// 1) trailing digits 확인
|
|
367
711
|
let allDigits = true;
|
|
368
|
-
for (let i = digitsStart
|
|
369
|
-
|
|
712
|
+
for (let i = digitsStart; i < inputLen; i++) {
|
|
713
|
+
const c = input.charCodeAt(i);
|
|
714
|
+
if (c < 48 || c > 57) {
|
|
370
715
|
allDigits = false;
|
|
371
716
|
break;
|
|
372
717
|
}
|
|
373
718
|
}
|
|
374
719
|
if (!allDigits)
|
|
375
720
|
continue;
|
|
376
|
-
const
|
|
377
|
-
const paddingBits = parseInt(
|
|
721
|
+
const digitStr = input.substring(digitsStart);
|
|
722
|
+
const paddingBits = parseInt(digitStr, 10);
|
|
378
723
|
if (Number.isNaN(paddingBits) ||
|
|
379
724
|
paddingBits < 0 ||
|
|
380
725
|
paddingBits >= this.effectiveBitLength ||
|
|
381
|
-
|
|
726
|
+
digitStr !== paddingBits.toString())
|
|
382
727
|
continue;
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
paddingBits,
|
|
395
|
-
isCompressed: true,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
728
|
+
// 2) digits 앞에서 마커들 역순 확인
|
|
729
|
+
let pos = digitsStart;
|
|
730
|
+
let isEncrypted = false;
|
|
731
|
+
let isCompressed = false;
|
|
732
|
+
if (pos >= ENCRYPT_MARKER.length && input.substring(pos - ENCRYPT_MARKER.length, pos) === ENCRYPT_MARKER) {
|
|
733
|
+
isEncrypted = true;
|
|
734
|
+
pos -= ENCRYPT_MARKER.length;
|
|
735
|
+
}
|
|
736
|
+
if (pos >= COMPRESS_MARKER.length && input.substring(pos - COMPRESS_MARKER.length, pos) === COMPRESS_MARKER) {
|
|
737
|
+
isCompressed = true;
|
|
738
|
+
pos -= COMPRESS_MARKER.length;
|
|
398
739
|
}
|
|
399
|
-
//
|
|
400
|
-
const padStart =
|
|
401
|
-
if (padStart >= 0 &&
|
|
740
|
+
// 3) 마커 앞에서 padding 문자 확인
|
|
741
|
+
const padStart = pos - padLen;
|
|
742
|
+
if (padStart >= 0 && input.substring(padStart, pos) === pad) {
|
|
402
743
|
if (padStart % this.charLength !== 0) {
|
|
403
744
|
throw new Error(`[Ddu64 decode] Invalid padding format. Misaligned padding marker`);
|
|
404
745
|
}
|
|
405
746
|
return {
|
|
406
747
|
cleanedInput: input.substring(0, padStart),
|
|
407
748
|
paddingBits,
|
|
408
|
-
isCompressed
|
|
749
|
+
isCompressed,
|
|
750
|
+
isEncrypted,
|
|
409
751
|
};
|
|
410
752
|
}
|
|
411
753
|
}
|
|
412
|
-
// Fallback:
|
|
754
|
+
// Fallback: 역순 탐색이 유효한 패딩을 찾지 못한 경우,
|
|
755
|
+
// padding 문자가 존재하지만 tail이 잘못된 형식인지 확인하여 에러 보고
|
|
413
756
|
const lastPadIdx = input.lastIndexOf(pad);
|
|
414
757
|
if (lastPadIdx >= 0 && lastPadIdx % this.charLength === 0) {
|
|
415
758
|
const tailStart = lastPadIdx + padLen;
|
|
416
759
|
if (tailStart >= inputLen) {
|
|
417
760
|
throw new Error(`[Ddu64 decode] Invalid padding format. Missing padding length`);
|
|
418
761
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (hasMarker) {
|
|
422
|
-
tail = tail.substring(markerLen);
|
|
423
|
-
if (!tail) {
|
|
424
|
-
throw new Error(`[Ddu64 decode] Invalid padding format. Missing padding length`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
const paddingBits = parseInt(tail, 10);
|
|
428
|
-
if (Number.isNaN(paddingBits) ||
|
|
429
|
-
tail !== paddingBits.toString() ||
|
|
430
|
-
paddingBits < 0 ||
|
|
431
|
-
paddingBits >= this.effectiveBitLength) {
|
|
432
|
-
throw new Error(`[Ddu64 decode] Invalid padding format. Got: "${tail}"`);
|
|
433
|
-
}
|
|
434
|
-
const isCompressed = input
|
|
435
|
-
.slice(lastPadIdx + padLen)
|
|
436
|
-
.startsWith(COMPRESS_MARKER);
|
|
437
|
-
return {
|
|
438
|
-
cleanedInput: input.substring(0, lastPadIdx),
|
|
439
|
-
paddingBits,
|
|
440
|
-
isCompressed,
|
|
441
|
-
};
|
|
762
|
+
const tail = input.substring(tailStart);
|
|
763
|
+
throw new Error(`[Ddu64 decode] Invalid padding format. Got: "${tail}"`);
|
|
442
764
|
}
|
|
443
|
-
return
|
|
765
|
+
return noFooter;
|
|
444
766
|
}
|
|
445
767
|
// --------------------------------------------------------------------------
|
|
446
768
|
// 인코딩 (Fast 모드 - 16비트 이하)
|
|
@@ -448,7 +770,7 @@ export class Ddu64 extends BaseDdu {
|
|
|
448
770
|
/**
|
|
449
771
|
* 일반 정수 연산을 사용한 빠른 인코딩
|
|
450
772
|
*/
|
|
451
|
-
encodeFast(bufferInput, compress) {
|
|
773
|
+
encodeFast(bufferInput, compress, encrypt) {
|
|
452
774
|
const inputLen = bufferInput.length;
|
|
453
775
|
if (inputLen === 0)
|
|
454
776
|
return "";
|
|
@@ -503,13 +825,11 @@ export class Ddu64 extends BaseDdu {
|
|
|
503
825
|
resultParts[resultIdx++] = dduChar[index - div * dduLength];
|
|
504
826
|
}
|
|
505
827
|
resultParts[resultIdx++] = paddingChar;
|
|
506
|
-
resultParts[resultIdx++] = compress
|
|
507
|
-
? COMPRESS_MARKER + paddingBits.toString()
|
|
508
|
-
: paddingBits.toString();
|
|
828
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + paddingBits.toString();
|
|
509
829
|
}
|
|
510
|
-
else if (compress) {
|
|
830
|
+
else if (compress || encrypt) {
|
|
511
831
|
resultParts[resultIdx++] = paddingChar;
|
|
512
|
-
resultParts[resultIdx++] = COMPRESS_MARKER + "0";
|
|
832
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + "0";
|
|
513
833
|
}
|
|
514
834
|
resultParts.length = resultIdx;
|
|
515
835
|
return resultParts.join("");
|
|
@@ -579,8 +899,40 @@ export class Ddu64 extends BaseDdu {
|
|
|
579
899
|
}
|
|
580
900
|
}
|
|
581
901
|
}
|
|
902
|
+
else if (this.useAsciiLookup && this.fastAsciiLookup && charLength === 1) {
|
|
903
|
+
// 가변 길이 charset - ASCII 최적화 경로
|
|
904
|
+
const asciiLookup = this.fastAsciiLookup;
|
|
905
|
+
const maxVal = this.maxBinaryValue;
|
|
906
|
+
for (let i = 0; i < inputLen; i += chunkSize) {
|
|
907
|
+
const code1 = cleanedInput.charCodeAt(i);
|
|
908
|
+
const code2 = cleanedInput.charCodeAt(i + 1);
|
|
909
|
+
const v1 = code1 < 128 ? asciiLookup[code1] : -1;
|
|
910
|
+
const v2 = code2 < 128 ? asciiLookup[code2] : -1;
|
|
911
|
+
if (v1 < 0) {
|
|
912
|
+
throw new Error(`[Ddu64 decode] Invalid character "${cleanedInput[i]}" at ${i}`);
|
|
913
|
+
}
|
|
914
|
+
if (v2 < 0) {
|
|
915
|
+
throw new Error(`[Ddu64 decode] Invalid character "${cleanedInput[i + 1]}" at ${i + charLength}`);
|
|
916
|
+
}
|
|
917
|
+
const value = v1 * dduLength + v2;
|
|
918
|
+
if (value >= maxVal) {
|
|
919
|
+
throw new Error(`[Ddu64 decode] Value ${value} exceeds range`);
|
|
920
|
+
}
|
|
921
|
+
accumulator = (accumulator << bitLength) | value;
|
|
922
|
+
accumulatorBits += bitLength;
|
|
923
|
+
if (i + chunkSize >= inputLen && paddingBits > 0) {
|
|
924
|
+
accumulator >>= paddingBits;
|
|
925
|
+
accumulatorBits -= paddingBits;
|
|
926
|
+
}
|
|
927
|
+
while (accumulatorBits >= BYTE_BITS) {
|
|
928
|
+
accumulatorBits -= BYTE_BITS;
|
|
929
|
+
buffer[bufIdx++] = (accumulator >> accumulatorBits) & BYTE_MASK;
|
|
930
|
+
accumulator &= (1 << accumulatorBits) - 1;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
582
934
|
else {
|
|
583
|
-
// 가변 길이 charset
|
|
935
|
+
// 가변 길이 charset - 일반 룩업 경로
|
|
584
936
|
const lookup = this.dduBinaryLookup;
|
|
585
937
|
for (let i = 0; i < inputLen; i += chunkSize) {
|
|
586
938
|
const c1 = cleanedInput.slice(i, i + charLength);
|
|
@@ -618,7 +970,7 @@ export class Ddu64 extends BaseDdu {
|
|
|
618
970
|
/**
|
|
619
971
|
* BigInt를 사용한 대형 비트 인코딩
|
|
620
972
|
*/
|
|
621
|
-
encodeBigInt(bufferInput, compress) {
|
|
973
|
+
encodeBigInt(bufferInput, compress, encrypt) {
|
|
622
974
|
const inputLen = bufferInput.length;
|
|
623
975
|
if (inputLen === 0)
|
|
624
976
|
return "";
|
|
@@ -673,13 +1025,11 @@ export class Ddu64 extends BaseDdu {
|
|
|
673
1025
|
resultParts[resultIdx++] = dduChar[index - div * dduLength];
|
|
674
1026
|
}
|
|
675
1027
|
resultParts[resultIdx++] = this.paddingChar;
|
|
676
|
-
resultParts[resultIdx++] = compress
|
|
677
|
-
? COMPRESS_MARKER + paddingBits.toString()
|
|
678
|
-
: paddingBits.toString();
|
|
1028
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + paddingBits.toString();
|
|
679
1029
|
}
|
|
680
|
-
else if (compress) {
|
|
1030
|
+
else if (compress || encrypt) {
|
|
681
1031
|
resultParts[resultIdx++] = this.paddingChar;
|
|
682
|
-
resultParts[resultIdx++] = COMPRESS_MARKER + "0";
|
|
1032
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + "0";
|
|
683
1033
|
}
|
|
684
1034
|
resultParts.length = resultIdx;
|
|
685
1035
|
return resultParts.join("");
|
|
@@ -764,55 +1114,58 @@ export class Ddu64 extends BaseDdu {
|
|
|
764
1114
|
* Charset을 정규화합니다.
|
|
765
1115
|
*/
|
|
766
1116
|
normalizeCharSet(current, shouldThrow, dduOptions) {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
1117
|
+
// 1차 시도: 주어진 charset으로 정규화
|
|
1118
|
+
// 실패 시 1회만 fallback 시도 (동일 fallback을 반복해도 결과는 동일)
|
|
1119
|
+
const attempts = [current, null];
|
|
1120
|
+
for (const attempt of attempts) {
|
|
1121
|
+
const state = attempt ? { ...attempt } : this.getFallbackCharSet(dduOptions);
|
|
771
1122
|
try {
|
|
772
1123
|
// 중복 문자 제거
|
|
773
|
-
|
|
774
|
-
|
|
1124
|
+
let charSet = state.charSet;
|
|
1125
|
+
let requiredLength = state.requiredLength;
|
|
1126
|
+
const uniqueChars = Array.from(new Set(charSet));
|
|
1127
|
+
if (uniqueChars.length !== charSet.length) {
|
|
775
1128
|
if (shouldThrow) {
|
|
776
|
-
const duplicates =
|
|
1129
|
+
const duplicates = charSet.filter((c, i) => charSet.indexOf(c) !== i);
|
|
777
1130
|
throw new Error(`[Ddu64 normalizeCharSet] Character set contains duplicate characters: [${[...new Set(duplicates)].join(", ")}]`);
|
|
778
1131
|
}
|
|
779
|
-
|
|
1132
|
+
charSet = uniqueChars;
|
|
780
1133
|
if (!state.isPredefined)
|
|
781
|
-
|
|
1134
|
+
requiredLength = charSet.length;
|
|
782
1135
|
}
|
|
783
1136
|
// 문자 수 검증
|
|
784
|
-
if (
|
|
785
|
-
throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${
|
|
1137
|
+
if (charSet.length < requiredLength) {
|
|
1138
|
+
throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${requiredLength}, Has: ${charSet.length}`);
|
|
786
1139
|
}
|
|
787
|
-
if (
|
|
1140
|
+
if (requiredLength < 2) {
|
|
788
1141
|
throw new Error(`[Ddu64 normalizeCharSet] At least 2 unique characters required.`);
|
|
789
1142
|
}
|
|
790
|
-
if (
|
|
1143
|
+
if (charSet.length === 0) {
|
|
791
1144
|
throw new Error(`[Ddu64 normalizeCharSet] Empty charset.`);
|
|
792
1145
|
}
|
|
793
1146
|
// 문자 길이 일관성 검증
|
|
794
|
-
const charLength =
|
|
795
|
-
const invalidChar =
|
|
1147
|
+
const charLength = charSet[0].length;
|
|
1148
|
+
const invalidChar = charSet.find((c) => c.length !== charLength);
|
|
796
1149
|
if (invalidChar) {
|
|
797
1150
|
if (shouldThrow) {
|
|
798
1151
|
throw new Error(`[Ddu64 normalizeCharSet] Inconsistent char length. Expected ${charLength}, found "${invalidChar}" (${invalidChar.length})`);
|
|
799
1152
|
}
|
|
800
|
-
|
|
1153
|
+
continue; // fallback으로 재시도
|
|
801
1154
|
}
|
|
802
1155
|
// 패딩 검증
|
|
803
1156
|
if (state.padding.length !== charLength) {
|
|
804
1157
|
throw new Error(`[Ddu64 normalizeCharSet] Padding length mismatch. Expected ${charLength}, got ${state.padding.length}`);
|
|
805
1158
|
}
|
|
806
|
-
if (
|
|
1159
|
+
if (charSet.includes(state.padding)) {
|
|
807
1160
|
if (shouldThrow) {
|
|
808
1161
|
throw new Error(`[Ddu64 normalizeCharSet] Padding character "${state.padding}" conflicts with charset.`);
|
|
809
1162
|
}
|
|
810
|
-
|
|
1163
|
+
charSet = charSet.filter((c) => c !== state.padding);
|
|
811
1164
|
}
|
|
812
1165
|
// 불필요한 배열 복사 방지
|
|
813
|
-
const finalSet =
|
|
814
|
-
?
|
|
815
|
-
:
|
|
1166
|
+
const finalSet = charSet.length === requiredLength
|
|
1167
|
+
? charSet
|
|
1168
|
+
: charSet.slice(0, requiredLength);
|
|
816
1169
|
return {
|
|
817
1170
|
charSet: finalSet,
|
|
818
1171
|
padding: state.padding,
|
|
@@ -821,13 +1174,12 @@ export class Ddu64 extends BaseDdu {
|
|
|
821
1174
|
};
|
|
822
1175
|
}
|
|
823
1176
|
catch (e) {
|
|
824
|
-
if (shouldThrow
|
|
1177
|
+
if (shouldThrow)
|
|
825
1178
|
throw e;
|
|
826
|
-
|
|
827
|
-
retryCount++;
|
|
1179
|
+
// fallback 시도로 continue
|
|
828
1180
|
}
|
|
829
1181
|
}
|
|
830
|
-
// 최종 fallback
|
|
1182
|
+
// 최종 fallback (이론상 도달 불가, 방어 코드)
|
|
831
1183
|
const fallback = this.getFallbackCharSet(dduOptions);
|
|
832
1184
|
return {
|
|
833
1185
|
charSet: fallback.charSet.slice(0, fallback.requiredLength),
|
|
@@ -933,6 +1285,33 @@ export class Ddu64 extends BaseDdu {
|
|
|
933
1285
|
throw new Error(`CharSet with symbol ${symbol} not found`);
|
|
934
1286
|
return cs;
|
|
935
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* URL-Safe 모드 시 charset/padding이 역변환 대상 문자를 포함하지 않는지 검증합니다.
|
|
1290
|
+
* 역변환 대상 문자("-", "_", ".")가 charset이나 padding에 있으면
|
|
1291
|
+
* fromUrlSafe 시 해당 문자가 "+", "/", "="로 변환되어 데이터가 손상됩니다.
|
|
1292
|
+
*
|
|
1293
|
+
* @returns URL-Safe 모드를 활성화해도 안전한 경우 true
|
|
1294
|
+
*/
|
|
1295
|
+
isUrlSafeCompatible(charSet, paddingChar, shouldThrow) {
|
|
1296
|
+
const conflictChars = Object.keys(URL_SAFE_REVERSE_MAP); // ["-", "_", "."]
|
|
1297
|
+
for (const ch of conflictChars) {
|
|
1298
|
+
for (const c of charSet) {
|
|
1299
|
+
if (c.includes(ch)) {
|
|
1300
|
+
const msg = `[Ddu64 Constructor] URL-Safe mode conflict: charset character "${c}" contains "${ch}" which would be transformed to "${URL_SAFE_REVERSE_MAP[ch]}" during decoding.`;
|
|
1301
|
+
if (shouldThrow)
|
|
1302
|
+
throw new Error(msg);
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (paddingChar.includes(ch)) {
|
|
1307
|
+
const msg = `[Ddu64 Constructor] URL-Safe mode conflict: padding character "${paddingChar}" contains "${ch}" which would be transformed to "${URL_SAFE_REVERSE_MAP[ch]}" during decoding.`;
|
|
1308
|
+
if (shouldThrow)
|
|
1309
|
+
throw new Error(msg);
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return true;
|
|
1314
|
+
}
|
|
936
1315
|
/**
|
|
937
1316
|
* 커스텀 charset의 조합 중복을 검증합니다.
|
|
938
1317
|
*/
|