@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
|
@@ -5,6 +5,7 @@ const zlib_1 = require("zlib");
|
|
|
5
5
|
const BaseDdu_1 = require("../base/BaseDdu");
|
|
6
6
|
const types_1 = require("../types");
|
|
7
7
|
const charSets_1 = require("../charSets");
|
|
8
|
+
const crypto_1 = require("../utils/crypto");
|
|
8
9
|
// ============================================================================
|
|
9
10
|
// 상수 정의
|
|
10
11
|
// ============================================================================
|
|
@@ -16,10 +17,56 @@ const MAX_FAST_BITS = 16;
|
|
|
16
17
|
const BYTE_MASK = 0xff;
|
|
17
18
|
/** 압축 데이터 식별 마커 */
|
|
18
19
|
const COMPRESS_MARKER = "ELYSIA";
|
|
20
|
+
/** 체크섬 마커 */
|
|
21
|
+
const CHECKSUM_MARKER = "CHK";
|
|
22
|
+
/** 암호화 마커 */
|
|
23
|
+
const ENCRYPT_MARKER = "ENC";
|
|
19
24
|
/** 기본 최대 디코딩 바이트 수 (64MB) */
|
|
20
25
|
const DEFAULT_MAX_DECODED_BYTES = 64 * 1024 * 1024;
|
|
21
26
|
/** 기본 최대 압축해제 바이트 수 (64MB) */
|
|
22
27
|
const DEFAULT_MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
/** CRC32 룩업 테이블 (바이트 단위 연산으로 비트 루프 대비 4~8배 빠름) */
|
|
29
|
+
const CRC32_TABLE = (() => {
|
|
30
|
+
const table = new Uint32Array(256);
|
|
31
|
+
for (let i = 0; i < 256; i++) {
|
|
32
|
+
let crc = i;
|
|
33
|
+
for (let j = 0; j < 8; j++) {
|
|
34
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
35
|
+
}
|
|
36
|
+
table[i] = crc;
|
|
37
|
+
}
|
|
38
|
+
return table;
|
|
39
|
+
})();
|
|
40
|
+
/** URL-Safe 문자 매핑 */
|
|
41
|
+
const URL_SAFE_MAP = {
|
|
42
|
+
"+": "-",
|
|
43
|
+
"/": "_",
|
|
44
|
+
"=": ".",
|
|
45
|
+
};
|
|
46
|
+
/** URL-Safe 역방향 매핑 */
|
|
47
|
+
const URL_SAFE_REVERSE_MAP = {
|
|
48
|
+
"-": "+",
|
|
49
|
+
"_": "/",
|
|
50
|
+
".": "=",
|
|
51
|
+
};
|
|
52
|
+
/** 인코딩 진행률 단계별 퍼센트 */
|
|
53
|
+
const ENCODE_PROGRESS = {
|
|
54
|
+
START: 0,
|
|
55
|
+
ENCRYPT: 15,
|
|
56
|
+
CHECKSUM: 20,
|
|
57
|
+
COMPRESS: 40,
|
|
58
|
+
ENCODE: 50,
|
|
59
|
+
DONE: 100,
|
|
60
|
+
};
|
|
61
|
+
/** 디코딩 진행률 단계별 퍼센트 */
|
|
62
|
+
const DECODE_PROGRESS = {
|
|
63
|
+
START: 0,
|
|
64
|
+
DECODE: 20,
|
|
65
|
+
DECOMPRESS: 50,
|
|
66
|
+
CHECKSUM: 70,
|
|
67
|
+
DECRYPT: 85,
|
|
68
|
+
DONE: 100,
|
|
69
|
+
};
|
|
23
70
|
// ============================================================================
|
|
24
71
|
// Ddu64 클래스
|
|
25
72
|
// ============================================================================
|
|
@@ -72,7 +119,8 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
72
119
|
this.fastAsciiLookup = null;
|
|
73
120
|
/** ASCII 룩업 사용 여부 */
|
|
74
121
|
this.useAsciiLookup = false;
|
|
75
|
-
|
|
122
|
+
// throwOnError 우선, useBuildErrorReturn은 하위 호환
|
|
123
|
+
const shouldThrow = dduOptions?.throwOnError ?? dduOptions?.useBuildErrorReturn ?? false;
|
|
76
124
|
// charset 초기화
|
|
77
125
|
const initial = this.resolveInitialCharSet(dduChar, paddingChar, dduOptions, shouldThrow);
|
|
78
126
|
const normalized = this.normalizeCharSet(initial, shouldThrow, dduOptions);
|
|
@@ -125,6 +173,18 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
125
173
|
if (this.charLength === 1 && !this.isPredefinedCharSet) {
|
|
126
174
|
this.validateCombinationDuplicates(this.dduChar, this.paddingChar, dduLength);
|
|
127
175
|
}
|
|
176
|
+
// 새로운 옵션들 초기화
|
|
177
|
+
const requestUrlSafe = dduOptions?.urlSafe ?? false;
|
|
178
|
+
this.urlSafe = requestUrlSafe
|
|
179
|
+
? this.isUrlSafeCompatible(this.dduChar, this.paddingChar, shouldThrow)
|
|
180
|
+
: false;
|
|
181
|
+
this.encryptionKeyHash = dduOptions?.encryptionKey
|
|
182
|
+
? (0, crypto_1.deriveKey)(dduOptions.encryptionKey)
|
|
183
|
+
: undefined;
|
|
184
|
+
this.defaultChecksum = dduOptions?.checksum ?? false;
|
|
185
|
+
this.defaultChunkSize = dduOptions?.chunkSize;
|
|
186
|
+
this.defaultChunkSeparator = dduOptions?.chunkSeparator ?? "\n";
|
|
187
|
+
this.defaultCompressionLevel = Math.min(9, Math.max(1, Math.floor(dduOptions?.compressionLevel ?? 6)));
|
|
128
188
|
}
|
|
129
189
|
// --------------------------------------------------------------------------
|
|
130
190
|
// 공개 메서드
|
|
@@ -135,32 +195,92 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
135
195
|
* @param input - 인코딩할 문자열 또는 Buffer
|
|
136
196
|
* @param options - 인코딩 옵션
|
|
137
197
|
* @param options.compress - 압축 사용 여부 (기본값: 생성자 설정)
|
|
198
|
+
* @param options.checksum - 체크섬 추가 여부
|
|
199
|
+
* @param options.chunkSize - 청크 분할 크기
|
|
200
|
+
* @param options.chunkSeparator - 청크 구분자
|
|
201
|
+
* @param options.onProgress - 진행률 콜백
|
|
138
202
|
* @returns 인코딩된 문자열
|
|
139
203
|
*
|
|
140
204
|
* @example
|
|
141
205
|
* encoder.encode("Hello World!");
|
|
142
206
|
* encoder.encode(buffer, { compress: true });
|
|
207
|
+
* encoder.encode(data, { checksum: true, chunkSize: 76 });
|
|
143
208
|
*/
|
|
144
209
|
encode(input, options) {
|
|
210
|
+
return this.encodeInternal(input, options).encoded;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 인코딩 핵심 로직. encode()와 getStats()가 공유합니다.
|
|
214
|
+
* 압축 크기 등 메타데이터도 함께 반환하여 중복 deflateSync 호출을 방지합니다.
|
|
215
|
+
*/
|
|
216
|
+
encodeInternal(input, options) {
|
|
145
217
|
const shouldCompress = options?.compress ?? this.defaultCompress;
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
218
|
+
const shouldChecksum = options?.checksum ?? this.defaultChecksum;
|
|
219
|
+
const chunkSize = options?.chunkSize ?? this.defaultChunkSize;
|
|
220
|
+
const chunkSeparator = options?.chunkSeparator ?? this.defaultChunkSeparator;
|
|
221
|
+
const onProgress = options?.onProgress;
|
|
222
|
+
let workingBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
|
|
223
|
+
const totalBytes = workingBuffer.length;
|
|
224
|
+
// 진행률 콜백 호출 (시작)
|
|
225
|
+
if (onProgress) {
|
|
226
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.START, stage: "start" });
|
|
227
|
+
}
|
|
228
|
+
// 암호화 처리
|
|
229
|
+
let isEncrypted = false;
|
|
230
|
+
if (this.encryptionKeyHash) {
|
|
231
|
+
workingBuffer = this.encryptData(workingBuffer);
|
|
232
|
+
isEncrypted = true;
|
|
233
|
+
if (onProgress) {
|
|
234
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.ENCRYPT, stage: "encrypt" });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// 체크섬 계산
|
|
238
|
+
let checksum = "";
|
|
239
|
+
if (shouldChecksum) {
|
|
240
|
+
checksum = this.calculateCRC32(workingBuffer);
|
|
241
|
+
if (onProgress) {
|
|
242
|
+
onProgress({ processedBytes: 0, totalBytes, percent: ENCODE_PROGRESS.CHECKSUM, stage: "checksum" });
|
|
243
|
+
}
|
|
152
244
|
}
|
|
153
|
-
// 압축
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
245
|
+
// 압축 처리
|
|
246
|
+
let useCompression = false;
|
|
247
|
+
let compressedSize;
|
|
248
|
+
if (shouldCompress) {
|
|
249
|
+
const level = options?.compressionLevel ?? this.defaultCompressionLevel;
|
|
250
|
+
const compressedBuffer = (0, zlib_1.deflateSync)(workingBuffer, { level: Math.min(9, Math.max(1, level)) });
|
|
251
|
+
compressedSize = compressedBuffer.length;
|
|
252
|
+
if (compressedBuffer.length < workingBuffer.length) {
|
|
253
|
+
workingBuffer = compressedBuffer;
|
|
254
|
+
useCompression = true;
|
|
255
|
+
}
|
|
256
|
+
if (onProgress) {
|
|
257
|
+
onProgress({ processedBytes: Math.floor(totalBytes * 0.4), totalBytes, percent: ENCODE_PROGRESS.COMPRESS, stage: "compress" });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// 인코딩 수행
|
|
261
|
+
if (onProgress) {
|
|
262
|
+
onProgress({ processedBytes: Math.floor(totalBytes * 0.5), totalBytes, percent: ENCODE_PROGRESS.ENCODE, stage: "encode" });
|
|
263
|
+
}
|
|
264
|
+
let result = this.effectiveBitLength <= MAX_FAST_BITS
|
|
265
|
+
? this.encodeFast(workingBuffer, useCompression, isEncrypted)
|
|
266
|
+
: this.encodeBigInt(workingBuffer, useCompression, isEncrypted);
|
|
267
|
+
// 체크섬 추가
|
|
268
|
+
if (shouldChecksum && checksum) {
|
|
269
|
+
result = result + CHECKSUM_MARKER + checksum;
|
|
270
|
+
}
|
|
271
|
+
// URL-Safe 변환
|
|
272
|
+
if (this.urlSafe) {
|
|
273
|
+
result = this.toUrlSafe(result);
|
|
160
274
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
275
|
+
// 청크 분할
|
|
276
|
+
if (chunkSize && chunkSize > 0) {
|
|
277
|
+
result = this.splitIntoChunks(result, chunkSize, chunkSeparator);
|
|
278
|
+
}
|
|
279
|
+
// 진행률 콜백 호출 (완료)
|
|
280
|
+
if (onProgress) {
|
|
281
|
+
onProgress({ processedBytes: totalBytes, totalBytes, percent: ENCODE_PROGRESS.DONE, stage: "done" });
|
|
282
|
+
}
|
|
283
|
+
return { encoded: result, compressedSize };
|
|
164
284
|
}
|
|
165
285
|
/**
|
|
166
286
|
* 인코딩된 문자열을 Buffer로 디코딩합니다.
|
|
@@ -169,14 +289,37 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
169
289
|
* @param options - 디코딩 옵션
|
|
170
290
|
* @param options.maxDecodedBytes - 최대 디코딩 바이트 수
|
|
171
291
|
* @param options.maxDecompressedBytes - 최대 압축해제 바이트 수
|
|
292
|
+
* @param options.onProgress - 진행률 콜백
|
|
172
293
|
* @returns 디코딩된 Buffer
|
|
173
294
|
*
|
|
174
295
|
* @throws 잘못된 문자가 포함된 경우
|
|
175
296
|
* @throws 패딩 형식이 잘못된 경우
|
|
176
297
|
* @throws 크기 제한 초과 시
|
|
298
|
+
* @throws 체크섬 불일치 시
|
|
177
299
|
*/
|
|
178
300
|
decodeToBuffer(input, options) {
|
|
179
|
-
const
|
|
301
|
+
const shouldChecksum = options?.checksum ?? this.defaultChecksum;
|
|
302
|
+
const onProgress = options?.onProgress;
|
|
303
|
+
let workingInput = input;
|
|
304
|
+
const inputLength = input.length;
|
|
305
|
+
// 진행률 콜백 호출 (시작)
|
|
306
|
+
if (onProgress) {
|
|
307
|
+
onProgress({ processedBytes: 0, totalBytes: inputLength, percent: DECODE_PROGRESS.START, stage: "start" });
|
|
308
|
+
}
|
|
309
|
+
// 청크 제거 (줄바꿈 등 구분자 제거)
|
|
310
|
+
workingInput = this.removeChunks(workingInput);
|
|
311
|
+
// URL-Safe 역변환
|
|
312
|
+
if (this.urlSafe) {
|
|
313
|
+
workingInput = this.fromUrlSafe(workingInput);
|
|
314
|
+
}
|
|
315
|
+
// 체크섬 추출 (체크섬이 활성화된 경우에만 수행하여 오탐지 방지)
|
|
316
|
+
let extractedChecksum = null;
|
|
317
|
+
if (shouldChecksum) {
|
|
318
|
+
const result = this.extractChecksum(workingInput);
|
|
319
|
+
extractedChecksum = result.checksum;
|
|
320
|
+
workingInput = result.data;
|
|
321
|
+
}
|
|
322
|
+
const { cleanedInput, paddingBits, isCompressed, isEncrypted } = this.parseFooter(workingInput);
|
|
180
323
|
this.assertEncodedInputAligned(cleanedInput);
|
|
181
324
|
// 디코딩 크기 검증
|
|
182
325
|
const maxDecodedBytes = this.normalizeLimit(options?.maxDecodedBytes, this.defaultMaxDecodedBytes, true, "maxDecodedBytes");
|
|
@@ -185,14 +328,42 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
185
328
|
throw new Error(`[Ddu64 decode] Decoded output exceeds limit. Estimated: ${estimatedDecodedBytes} bytes, Limit: ${maxDecodedBytes} bytes`);
|
|
186
329
|
}
|
|
187
330
|
// 디코딩 수행
|
|
188
|
-
|
|
331
|
+
if (onProgress) {
|
|
332
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.2), totalBytes: inputLength, percent: DECODE_PROGRESS.DECODE, stage: "decode" });
|
|
333
|
+
}
|
|
334
|
+
let decoded = this.effectiveBitLength <= MAX_FAST_BITS
|
|
189
335
|
? this.decodeFast(cleanedInput, paddingBits)
|
|
190
336
|
: this.decodeBigInt(cleanedInput, paddingBits);
|
|
191
|
-
if (!isCompressed)
|
|
192
|
-
return decoded;
|
|
193
337
|
// 압축 해제
|
|
194
|
-
|
|
195
|
-
|
|
338
|
+
if (isCompressed) {
|
|
339
|
+
if (onProgress) {
|
|
340
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.5), totalBytes: inputLength, percent: DECODE_PROGRESS.DECOMPRESS, stage: "decompress" });
|
|
341
|
+
}
|
|
342
|
+
const maxDecompressedBytes = this.normalizeLimit(options?.maxDecompressedBytes, this.defaultMaxDecompressedBytes, true, "maxDecompressedBytes");
|
|
343
|
+
decoded = this.inflateWithLimit(decoded, maxDecompressedBytes);
|
|
344
|
+
}
|
|
345
|
+
// 체크섬 검증
|
|
346
|
+
if (extractedChecksum) {
|
|
347
|
+
if (onProgress) {
|
|
348
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.7), totalBytes: inputLength, percent: DECODE_PROGRESS.CHECKSUM, stage: "checksum" });
|
|
349
|
+
}
|
|
350
|
+
const calculatedChecksum = this.calculateCRC32(decoded);
|
|
351
|
+
if (calculatedChecksum !== extractedChecksum) {
|
|
352
|
+
throw new Error(`[Ddu64 decode] Checksum mismatch. Expected: ${extractedChecksum}, Got: ${calculatedChecksum}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// 복호화
|
|
356
|
+
if (isEncrypted && this.encryptionKeyHash) {
|
|
357
|
+
if (onProgress) {
|
|
358
|
+
onProgress({ processedBytes: Math.floor(inputLength * 0.85), totalBytes: inputLength, percent: DECODE_PROGRESS.DECRYPT, stage: "decrypt" });
|
|
359
|
+
}
|
|
360
|
+
decoded = this.decryptData(decoded);
|
|
361
|
+
}
|
|
362
|
+
// 진행률 콜백 호출 (완료)
|
|
363
|
+
if (onProgress) {
|
|
364
|
+
onProgress({ processedBytes: inputLength, totalBytes: inputLength, percent: DECODE_PROGRESS.DONE, stage: "done" });
|
|
365
|
+
}
|
|
366
|
+
return decoded;
|
|
196
367
|
}
|
|
197
368
|
/**
|
|
198
369
|
* 인코딩된 문자열을 원본 문자열로 디코딩합니다.
|
|
@@ -222,8 +393,205 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
222
393
|
defaultCompress: this.defaultCompress,
|
|
223
394
|
defaultMaxDecodedBytes: this.defaultMaxDecodedBytes,
|
|
224
395
|
defaultMaxDecompressedBytes: this.defaultMaxDecompressedBytes,
|
|
396
|
+
urlSafe: this.urlSafe,
|
|
397
|
+
hasEncryptionKey: !!this.encryptionKeyHash,
|
|
398
|
+
defaultChecksum: this.defaultChecksum,
|
|
399
|
+
defaultChunkSize: this.defaultChunkSize,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 인코딩 통계 정보를 반환합니다.
|
|
404
|
+
*
|
|
405
|
+
* @param input - 분석할 데이터
|
|
406
|
+
* @param options - 인코딩 옵션
|
|
407
|
+
* @returns 통계 정보 객체
|
|
408
|
+
*/
|
|
409
|
+
getStats(input, options) {
|
|
410
|
+
const shouldCompress = options?.compress ?? this.defaultCompress;
|
|
411
|
+
const originalBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
|
|
412
|
+
const originalSize = originalBuffer.length;
|
|
413
|
+
// encodeInternal을 통해 인코딩과 압축 크기를 한 번에 얻어 중복 deflateSync 방지
|
|
414
|
+
const { encoded, compressedSize } = this.encodeInternal(input, options);
|
|
415
|
+
const encodedSize = encoded.length;
|
|
416
|
+
const expansionRatio = originalSize > 0 ? encodedSize / originalSize : 0;
|
|
417
|
+
const compressionRatio = shouldCompress && compressedSize !== undefined && originalSize > 0
|
|
418
|
+
? compressedSize / originalSize
|
|
419
|
+
: undefined;
|
|
420
|
+
return {
|
|
421
|
+
originalSize,
|
|
422
|
+
encodedSize,
|
|
423
|
+
compressedSize,
|
|
424
|
+
compressionRatio,
|
|
425
|
+
expansionRatio,
|
|
426
|
+
charsetSize: this.dduChar.length,
|
|
427
|
+
bitLength: this.bitLength,
|
|
225
428
|
};
|
|
226
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* 비동기 인코딩을 수행합니다.
|
|
432
|
+
*
|
|
433
|
+
* 내부적으로 동기 encode()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
434
|
+
* 호출자가 즉시 블로킹되지 않도록 보장하지만, 인코딩 자체는 단일 동기 작업으로
|
|
435
|
+
* 수행되므로 대용량 데이터(수 MB 이상) 처리 시 이벤트 루프가 블로킹될 수 있습니다.
|
|
436
|
+
* 대용량 처리가 필요한 경우 스트림 API(createEncodeStream) 사용을 권장합니다.
|
|
437
|
+
*
|
|
438
|
+
* @param input - 인코딩할 데이터
|
|
439
|
+
* @param options - 인코딩 옵션
|
|
440
|
+
* @returns 인코딩된 문자열 Promise
|
|
441
|
+
*/
|
|
442
|
+
async encodeAsync(input, options) {
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
setImmediate(() => {
|
|
445
|
+
try {
|
|
446
|
+
resolve(this.encode(input, options));
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
reject(e);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* 비동기 디코딩을 수행합니다.
|
|
456
|
+
*
|
|
457
|
+
* 내부적으로 동기 decode()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
458
|
+
* 대용량 처리가 필요한 경우 스트림 API(createDecodeStream) 사용을 권장합니다.
|
|
459
|
+
*
|
|
460
|
+
* @param input - 디코딩할 인코딩된 문자열
|
|
461
|
+
* @param options - 디코딩 옵션
|
|
462
|
+
* @returns 디코딩된 문자열 Promise
|
|
463
|
+
*/
|
|
464
|
+
async decodeAsync(input, options) {
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
setImmediate(() => {
|
|
467
|
+
try {
|
|
468
|
+
resolve(this.decode(input, options));
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
reject(e);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* 비동기 디코딩을 Buffer로 수행합니다.
|
|
478
|
+
*
|
|
479
|
+
* 내부적으로 동기 decodeToBuffer()를 setImmediate로 이벤트 루프에 양보한 뒤 실행합니다.
|
|
480
|
+
* 대용량 처리가 필요한 경우 스트림 API(createDecodeStream) 사용을 권장합니다.
|
|
481
|
+
*
|
|
482
|
+
* @param input - 디코딩할 인코딩된 문자열
|
|
483
|
+
* @param options - 디코딩 옵션
|
|
484
|
+
* @returns 디코딩된 Buffer Promise
|
|
485
|
+
*/
|
|
486
|
+
async decodeToBufferAsync(input, options) {
|
|
487
|
+
return new Promise((resolve, reject) => {
|
|
488
|
+
setImmediate(() => {
|
|
489
|
+
try {
|
|
490
|
+
resolve(this.decodeToBuffer(input, options));
|
|
491
|
+
}
|
|
492
|
+
catch (e) {
|
|
493
|
+
reject(e);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
// --------------------------------------------------------------------------
|
|
499
|
+
// URL-Safe 메서드
|
|
500
|
+
// --------------------------------------------------------------------------
|
|
501
|
+
/**
|
|
502
|
+
* 문자열을 URL-Safe 형식으로 변환합니다.
|
|
503
|
+
* 단일 정규식 패스로 처리하여 split/join 3회 반복 대비 메모리/속도 개선
|
|
504
|
+
*/
|
|
505
|
+
toUrlSafe(input) {
|
|
506
|
+
return input.replace(/[+/=]/g, (c) => URL_SAFE_MAP[c]);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* URL-Safe 형식에서 원래 형식으로 복원합니다.
|
|
510
|
+
*/
|
|
511
|
+
fromUrlSafe(input) {
|
|
512
|
+
return input.replace(/[-_.]/g, (c) => URL_SAFE_REVERSE_MAP[c]);
|
|
513
|
+
}
|
|
514
|
+
// --------------------------------------------------------------------------
|
|
515
|
+
// 체크섬 메서드
|
|
516
|
+
// --------------------------------------------------------------------------
|
|
517
|
+
/**
|
|
518
|
+
* CRC32 체크섬을 계산합니다. (룩업 테이블 사용)
|
|
519
|
+
*/
|
|
520
|
+
calculateCRC32(data) {
|
|
521
|
+
let crc = 0xffffffff;
|
|
522
|
+
for (let i = 0; i < data.length; i++) {
|
|
523
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xff];
|
|
524
|
+
}
|
|
525
|
+
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, "0");
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 인코딩된 문자열에서 체크섬을 추출합니다.
|
|
529
|
+
*/
|
|
530
|
+
extractChecksum(input) {
|
|
531
|
+
const markerIndex = input.lastIndexOf(CHECKSUM_MARKER);
|
|
532
|
+
if (markerIndex === -1) {
|
|
533
|
+
return { data: input, checksum: null };
|
|
534
|
+
}
|
|
535
|
+
const checksum = input.slice(markerIndex + CHECKSUM_MARKER.length);
|
|
536
|
+
if (checksum.length !== 8 || !/^[0-9a-f]+$/i.test(checksum)) {
|
|
537
|
+
return { data: input, checksum: null };
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
data: input.slice(0, markerIndex),
|
|
541
|
+
checksum: checksum.toLowerCase(),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
// --------------------------------------------------------------------------
|
|
545
|
+
// 청크 분할 메서드
|
|
546
|
+
// --------------------------------------------------------------------------
|
|
547
|
+
/**
|
|
548
|
+
* 문자열을 청크로 분할합니다.
|
|
549
|
+
*/
|
|
550
|
+
splitIntoChunks(input, chunkSize, separator) {
|
|
551
|
+
if (chunkSize <= 0)
|
|
552
|
+
return input;
|
|
553
|
+
const chunks = [];
|
|
554
|
+
for (let i = 0; i < input.length; i += chunkSize) {
|
|
555
|
+
chunks.push(input.slice(i, i + chunkSize));
|
|
556
|
+
}
|
|
557
|
+
return chunks.join(separator);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 청크 구분자를 제거합니다.
|
|
561
|
+
* 줄바꿈(\r, \n)과 인스턴스에 설정된 청크 구분자만 제거합니다.
|
|
562
|
+
* 공백/탭 등은 charset에 포함될 수 있으므로 제거하지 않습니다.
|
|
563
|
+
*/
|
|
564
|
+
removeChunks(input) {
|
|
565
|
+
// 줄바꿈 문자(\r, \n)는 항상 제거 (기본 구분자 및 일반적 라인 구분)
|
|
566
|
+
let result = input.replace(/[\r\n]/g, "");
|
|
567
|
+
// 커스텀 구분자가 줄바꿈이 아닌 경우 추가로 제거
|
|
568
|
+
const sep = this.defaultChunkSeparator;
|
|
569
|
+
if (sep && sep !== "\n" && sep !== "\r\n" && sep !== "\r") {
|
|
570
|
+
result = result.split(sep).join("");
|
|
571
|
+
}
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
// --------------------------------------------------------------------------
|
|
575
|
+
// 암호화 메서드
|
|
576
|
+
// --------------------------------------------------------------------------
|
|
577
|
+
/**
|
|
578
|
+
* 데이터를 AES-256-GCM으로 암호화합니다.
|
|
579
|
+
*/
|
|
580
|
+
encryptData(data) {
|
|
581
|
+
if (!this.encryptionKeyHash) {
|
|
582
|
+
throw new Error("[Ddu64 encrypt] Encryption key is not set");
|
|
583
|
+
}
|
|
584
|
+
return (0, crypto_1.encryptAes256Gcm)(data, this.encryptionKeyHash);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* AES-256-GCM으로 암호화된 데이터를 복호화합니다.
|
|
588
|
+
*/
|
|
589
|
+
decryptData(data) {
|
|
590
|
+
if (!this.encryptionKeyHash) {
|
|
591
|
+
throw new Error("[Ddu64 decrypt] Encryption key is not set");
|
|
592
|
+
}
|
|
593
|
+
return (0, crypto_1.decryptAes256Gcm)(data, this.encryptionKeyHash);
|
|
594
|
+
}
|
|
227
595
|
// --------------------------------------------------------------------------
|
|
228
596
|
// 유틸리티 메서드
|
|
229
597
|
// --------------------------------------------------------------------------
|
|
@@ -280,143 +648,85 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
280
648
|
* 크기 제한을 적용하여 압축을 해제합니다.
|
|
281
649
|
*/
|
|
282
650
|
inflateWithLimit(data, maxBytes) {
|
|
283
|
-
|
|
284
|
-
return (0, zlib_1.inflateSync)(data);
|
|
285
|
-
try {
|
|
286
|
-
return (0, zlib_1.inflateSync)(data, { maxOutputLength: maxBytes });
|
|
287
|
-
}
|
|
288
|
-
catch (e) {
|
|
289
|
-
const msg = String(e?.message ?? "");
|
|
290
|
-
const code = String(e?.code ?? "");
|
|
291
|
-
// maxOutputLength 미지원 시 fallback
|
|
292
|
-
if (msg.toLowerCase().includes("maxoutputlength") ||
|
|
293
|
-
msg.toLowerCase().includes("unknown option") ||
|
|
294
|
-
code === "ERR_INVALID_ARG_VALUE") {
|
|
295
|
-
const inflated = (0, zlib_1.inflateSync)(data);
|
|
296
|
-
if (inflated.length > maxBytes) {
|
|
297
|
-
throw new Error(`[Ddu64 decode] Decompressed data exceeds limit. Size: ${inflated.length} bytes, Limit: ${maxBytes} bytes`);
|
|
298
|
-
}
|
|
299
|
-
return inflated;
|
|
300
|
-
}
|
|
301
|
-
// 출력 제한 초과
|
|
302
|
-
if (code === "ERR_BUFFER_TOO_LARGE" ||
|
|
303
|
-
msg.toLowerCase().includes("output length") ||
|
|
304
|
-
msg.toLowerCase().includes("buffer too large")) {
|
|
305
|
-
throw new Error(`[Ddu64 decode] Decompressed data exceeds limit. Limit: ${maxBytes} bytes`);
|
|
306
|
-
}
|
|
307
|
-
throw e;
|
|
308
|
-
}
|
|
651
|
+
return (0, crypto_1.inflateWithLimit)(data, maxBytes, "Ddu64 decode");
|
|
309
652
|
}
|
|
310
653
|
// --------------------------------------------------------------------------
|
|
311
654
|
// 푸터 파싱
|
|
312
655
|
// --------------------------------------------------------------------------
|
|
313
656
|
/**
|
|
314
657
|
* 인코딩된 문자열의 푸터(패딩 정보)를 파싱합니다.
|
|
658
|
+
*
|
|
659
|
+
* 푸터 형식: {encodedData}{paddingChar}[ELYSIA][ENC]{digits}
|
|
660
|
+
* 끝에서부터 역순으로 파싱하여 paddingChar가 숫자인 경우도 안전하게 처리합니다.
|
|
315
661
|
*/
|
|
316
662
|
parseFooter(input) {
|
|
317
663
|
const inputLen = input.length;
|
|
318
664
|
const pad = this.paddingChar;
|
|
319
665
|
const padLen = pad.length;
|
|
666
|
+
const noFooter = { cleanedInput: input, paddingBits: 0, isCompressed: false, isEncrypted: false };
|
|
320
667
|
if (inputLen < padLen)
|
|
321
|
-
return
|
|
322
|
-
const markerLen = COMPRESS_MARKER.length;
|
|
668
|
+
return noFooter;
|
|
323
669
|
const maxPaddingBits = Math.max(0, this.effectiveBitLength - 1);
|
|
324
670
|
const maxDigits = maxPaddingBits.toString().length;
|
|
325
|
-
//
|
|
326
|
-
const matchesAt = (str, pattern, start) => {
|
|
327
|
-
const pLen = pattern.length;
|
|
328
|
-
if (start < 0 || start + pLen > str.length)
|
|
329
|
-
return false;
|
|
330
|
-
for (let i = 0; i < pLen; i++) {
|
|
331
|
-
if (str.charCodeAt(start + i) !== pattern.charCodeAt(i))
|
|
332
|
-
return false;
|
|
333
|
-
}
|
|
334
|
-
return true;
|
|
335
|
-
};
|
|
336
|
-
const isDigitCode = (code) => code >= 48 && code <= 57;
|
|
337
|
-
// 푸터 패턴 탐색
|
|
671
|
+
// 끝에서부터 역순 파싱: digits → ENC → ELYSIA → paddingChar
|
|
338
672
|
for (let digitCount = Math.min(maxDigits, inputLen); digitCount >= 1; digitCount--) {
|
|
339
673
|
const digitsStart = inputLen - digitCount;
|
|
340
|
-
|
|
341
|
-
if (!isDigitCode(firstCode))
|
|
342
|
-
continue;
|
|
674
|
+
// 1) trailing digits 확인
|
|
343
675
|
let allDigits = true;
|
|
344
|
-
for (let i = digitsStart
|
|
345
|
-
|
|
676
|
+
for (let i = digitsStart; i < inputLen; i++) {
|
|
677
|
+
const c = input.charCodeAt(i);
|
|
678
|
+
if (c < 48 || c > 57) {
|
|
346
679
|
allDigits = false;
|
|
347
680
|
break;
|
|
348
681
|
}
|
|
349
682
|
}
|
|
350
683
|
if (!allDigits)
|
|
351
684
|
continue;
|
|
352
|
-
const
|
|
353
|
-
const paddingBits = parseInt(
|
|
685
|
+
const digitStr = input.substring(digitsStart);
|
|
686
|
+
const paddingBits = parseInt(digitStr, 10);
|
|
354
687
|
if (Number.isNaN(paddingBits) ||
|
|
355
688
|
paddingBits < 0 ||
|
|
356
689
|
paddingBits >= this.effectiveBitLength ||
|
|
357
|
-
|
|
690
|
+
digitStr !== paddingBits.toString())
|
|
358
691
|
continue;
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
paddingBits,
|
|
371
|
-
isCompressed: true,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
692
|
+
// 2) digits 앞에서 마커들 역순 확인
|
|
693
|
+
let pos = digitsStart;
|
|
694
|
+
let isEncrypted = false;
|
|
695
|
+
let isCompressed = false;
|
|
696
|
+
if (pos >= ENCRYPT_MARKER.length && input.substring(pos - ENCRYPT_MARKER.length, pos) === ENCRYPT_MARKER) {
|
|
697
|
+
isEncrypted = true;
|
|
698
|
+
pos -= ENCRYPT_MARKER.length;
|
|
699
|
+
}
|
|
700
|
+
if (pos >= COMPRESS_MARKER.length && input.substring(pos - COMPRESS_MARKER.length, pos) === COMPRESS_MARKER) {
|
|
701
|
+
isCompressed = true;
|
|
702
|
+
pos -= COMPRESS_MARKER.length;
|
|
374
703
|
}
|
|
375
|
-
//
|
|
376
|
-
const padStart =
|
|
377
|
-
if (padStart >= 0 &&
|
|
704
|
+
// 3) 마커 앞에서 padding 문자 확인
|
|
705
|
+
const padStart = pos - padLen;
|
|
706
|
+
if (padStart >= 0 && input.substring(padStart, pos) === pad) {
|
|
378
707
|
if (padStart % this.charLength !== 0) {
|
|
379
708
|
throw new Error(`[Ddu64 decode] Invalid padding format. Misaligned padding marker`);
|
|
380
709
|
}
|
|
381
710
|
return {
|
|
382
711
|
cleanedInput: input.substring(0, padStart),
|
|
383
712
|
paddingBits,
|
|
384
|
-
isCompressed
|
|
713
|
+
isCompressed,
|
|
714
|
+
isEncrypted,
|
|
385
715
|
};
|
|
386
716
|
}
|
|
387
717
|
}
|
|
388
|
-
// Fallback:
|
|
718
|
+
// Fallback: 역순 탐색이 유효한 패딩을 찾지 못한 경우,
|
|
719
|
+
// padding 문자가 존재하지만 tail이 잘못된 형식인지 확인하여 에러 보고
|
|
389
720
|
const lastPadIdx = input.lastIndexOf(pad);
|
|
390
721
|
if (lastPadIdx >= 0 && lastPadIdx % this.charLength === 0) {
|
|
391
722
|
const tailStart = lastPadIdx + padLen;
|
|
392
723
|
if (tailStart >= inputLen) {
|
|
393
724
|
throw new Error(`[Ddu64 decode] Invalid padding format. Missing padding length`);
|
|
394
725
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (hasMarker) {
|
|
398
|
-
tail = tail.substring(markerLen);
|
|
399
|
-
if (!tail) {
|
|
400
|
-
throw new Error(`[Ddu64 decode] Invalid padding format. Missing padding length`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
const paddingBits = parseInt(tail, 10);
|
|
404
|
-
if (Number.isNaN(paddingBits) ||
|
|
405
|
-
tail !== paddingBits.toString() ||
|
|
406
|
-
paddingBits < 0 ||
|
|
407
|
-
paddingBits >= this.effectiveBitLength) {
|
|
408
|
-
throw new Error(`[Ddu64 decode] Invalid padding format. Got: "${tail}"`);
|
|
409
|
-
}
|
|
410
|
-
const isCompressed = input
|
|
411
|
-
.slice(lastPadIdx + padLen)
|
|
412
|
-
.startsWith(COMPRESS_MARKER);
|
|
413
|
-
return {
|
|
414
|
-
cleanedInput: input.substring(0, lastPadIdx),
|
|
415
|
-
paddingBits,
|
|
416
|
-
isCompressed,
|
|
417
|
-
};
|
|
726
|
+
const tail = input.substring(tailStart);
|
|
727
|
+
throw new Error(`[Ddu64 decode] Invalid padding format. Got: "${tail}"`);
|
|
418
728
|
}
|
|
419
|
-
return
|
|
729
|
+
return noFooter;
|
|
420
730
|
}
|
|
421
731
|
// --------------------------------------------------------------------------
|
|
422
732
|
// 인코딩 (Fast 모드 - 16비트 이하)
|
|
@@ -424,7 +734,7 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
424
734
|
/**
|
|
425
735
|
* 일반 정수 연산을 사용한 빠른 인코딩
|
|
426
736
|
*/
|
|
427
|
-
encodeFast(bufferInput, compress) {
|
|
737
|
+
encodeFast(bufferInput, compress, encrypt) {
|
|
428
738
|
const inputLen = bufferInput.length;
|
|
429
739
|
if (inputLen === 0)
|
|
430
740
|
return "";
|
|
@@ -479,13 +789,11 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
479
789
|
resultParts[resultIdx++] = dduChar[index - div * dduLength];
|
|
480
790
|
}
|
|
481
791
|
resultParts[resultIdx++] = paddingChar;
|
|
482
|
-
resultParts[resultIdx++] = compress
|
|
483
|
-
? COMPRESS_MARKER + paddingBits.toString()
|
|
484
|
-
: paddingBits.toString();
|
|
792
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + paddingBits.toString();
|
|
485
793
|
}
|
|
486
|
-
else if (compress) {
|
|
794
|
+
else if (compress || encrypt) {
|
|
487
795
|
resultParts[resultIdx++] = paddingChar;
|
|
488
|
-
resultParts[resultIdx++] = COMPRESS_MARKER + "0";
|
|
796
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + "0";
|
|
489
797
|
}
|
|
490
798
|
resultParts.length = resultIdx;
|
|
491
799
|
return resultParts.join("");
|
|
@@ -555,8 +863,40 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
555
863
|
}
|
|
556
864
|
}
|
|
557
865
|
}
|
|
866
|
+
else if (this.useAsciiLookup && this.fastAsciiLookup && charLength === 1) {
|
|
867
|
+
// 가변 길이 charset - ASCII 최적화 경로
|
|
868
|
+
const asciiLookup = this.fastAsciiLookup;
|
|
869
|
+
const maxVal = this.maxBinaryValue;
|
|
870
|
+
for (let i = 0; i < inputLen; i += chunkSize) {
|
|
871
|
+
const code1 = cleanedInput.charCodeAt(i);
|
|
872
|
+
const code2 = cleanedInput.charCodeAt(i + 1);
|
|
873
|
+
const v1 = code1 < 128 ? asciiLookup[code1] : -1;
|
|
874
|
+
const v2 = code2 < 128 ? asciiLookup[code2] : -1;
|
|
875
|
+
if (v1 < 0) {
|
|
876
|
+
throw new Error(`[Ddu64 decode] Invalid character "${cleanedInput[i]}" at ${i}`);
|
|
877
|
+
}
|
|
878
|
+
if (v2 < 0) {
|
|
879
|
+
throw new Error(`[Ddu64 decode] Invalid character "${cleanedInput[i + 1]}" at ${i + charLength}`);
|
|
880
|
+
}
|
|
881
|
+
const value = v1 * dduLength + v2;
|
|
882
|
+
if (value >= maxVal) {
|
|
883
|
+
throw new Error(`[Ddu64 decode] Value ${value} exceeds range`);
|
|
884
|
+
}
|
|
885
|
+
accumulator = (accumulator << bitLength) | value;
|
|
886
|
+
accumulatorBits += bitLength;
|
|
887
|
+
if (i + chunkSize >= inputLen && paddingBits > 0) {
|
|
888
|
+
accumulator >>= paddingBits;
|
|
889
|
+
accumulatorBits -= paddingBits;
|
|
890
|
+
}
|
|
891
|
+
while (accumulatorBits >= BYTE_BITS) {
|
|
892
|
+
accumulatorBits -= BYTE_BITS;
|
|
893
|
+
buffer[bufIdx++] = (accumulator >> accumulatorBits) & BYTE_MASK;
|
|
894
|
+
accumulator &= (1 << accumulatorBits) - 1;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
558
898
|
else {
|
|
559
|
-
// 가변 길이 charset
|
|
899
|
+
// 가변 길이 charset - 일반 룩업 경로
|
|
560
900
|
const lookup = this.dduBinaryLookup;
|
|
561
901
|
for (let i = 0; i < inputLen; i += chunkSize) {
|
|
562
902
|
const c1 = cleanedInput.slice(i, i + charLength);
|
|
@@ -594,7 +934,7 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
594
934
|
/**
|
|
595
935
|
* BigInt를 사용한 대형 비트 인코딩
|
|
596
936
|
*/
|
|
597
|
-
encodeBigInt(bufferInput, compress) {
|
|
937
|
+
encodeBigInt(bufferInput, compress, encrypt) {
|
|
598
938
|
const inputLen = bufferInput.length;
|
|
599
939
|
if (inputLen === 0)
|
|
600
940
|
return "";
|
|
@@ -649,13 +989,11 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
649
989
|
resultParts[resultIdx++] = dduChar[index - div * dduLength];
|
|
650
990
|
}
|
|
651
991
|
resultParts[resultIdx++] = this.paddingChar;
|
|
652
|
-
resultParts[resultIdx++] = compress
|
|
653
|
-
? COMPRESS_MARKER + paddingBits.toString()
|
|
654
|
-
: paddingBits.toString();
|
|
992
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + paddingBits.toString();
|
|
655
993
|
}
|
|
656
|
-
else if (compress) {
|
|
994
|
+
else if (compress || encrypt) {
|
|
657
995
|
resultParts[resultIdx++] = this.paddingChar;
|
|
658
|
-
resultParts[resultIdx++] = COMPRESS_MARKER + "0";
|
|
996
|
+
resultParts[resultIdx++] = (compress ? COMPRESS_MARKER : "") + (encrypt ? ENCRYPT_MARKER : "") + "0";
|
|
659
997
|
}
|
|
660
998
|
resultParts.length = resultIdx;
|
|
661
999
|
return resultParts.join("");
|
|
@@ -740,55 +1078,58 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
740
1078
|
* Charset을 정규화합니다.
|
|
741
1079
|
*/
|
|
742
1080
|
normalizeCharSet(current, shouldThrow, dduOptions) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
1081
|
+
// 1차 시도: 주어진 charset으로 정규화
|
|
1082
|
+
// 실패 시 1회만 fallback 시도 (동일 fallback을 반복해도 결과는 동일)
|
|
1083
|
+
const attempts = [current, null];
|
|
1084
|
+
for (const attempt of attempts) {
|
|
1085
|
+
const state = attempt ? { ...attempt } : this.getFallbackCharSet(dduOptions);
|
|
747
1086
|
try {
|
|
748
1087
|
// 중복 문자 제거
|
|
749
|
-
|
|
750
|
-
|
|
1088
|
+
let charSet = state.charSet;
|
|
1089
|
+
let requiredLength = state.requiredLength;
|
|
1090
|
+
const uniqueChars = Array.from(new Set(charSet));
|
|
1091
|
+
if (uniqueChars.length !== charSet.length) {
|
|
751
1092
|
if (shouldThrow) {
|
|
752
|
-
const duplicates =
|
|
1093
|
+
const duplicates = charSet.filter((c, i) => charSet.indexOf(c) !== i);
|
|
753
1094
|
throw new Error(`[Ddu64 normalizeCharSet] Character set contains duplicate characters: [${[...new Set(duplicates)].join(", ")}]`);
|
|
754
1095
|
}
|
|
755
|
-
|
|
1096
|
+
charSet = uniqueChars;
|
|
756
1097
|
if (!state.isPredefined)
|
|
757
|
-
|
|
1098
|
+
requiredLength = charSet.length;
|
|
758
1099
|
}
|
|
759
1100
|
// 문자 수 검증
|
|
760
|
-
if (
|
|
761
|
-
throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${
|
|
1101
|
+
if (charSet.length < requiredLength) {
|
|
1102
|
+
throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${requiredLength}, Has: ${charSet.length}`);
|
|
762
1103
|
}
|
|
763
|
-
if (
|
|
1104
|
+
if (requiredLength < 2) {
|
|
764
1105
|
throw new Error(`[Ddu64 normalizeCharSet] At least 2 unique characters required.`);
|
|
765
1106
|
}
|
|
766
|
-
if (
|
|
1107
|
+
if (charSet.length === 0) {
|
|
767
1108
|
throw new Error(`[Ddu64 normalizeCharSet] Empty charset.`);
|
|
768
1109
|
}
|
|
769
1110
|
// 문자 길이 일관성 검증
|
|
770
|
-
const charLength =
|
|
771
|
-
const invalidChar =
|
|
1111
|
+
const charLength = charSet[0].length;
|
|
1112
|
+
const invalidChar = charSet.find((c) => c.length !== charLength);
|
|
772
1113
|
if (invalidChar) {
|
|
773
1114
|
if (shouldThrow) {
|
|
774
1115
|
throw new Error(`[Ddu64 normalizeCharSet] Inconsistent char length. Expected ${charLength}, found "${invalidChar}" (${invalidChar.length})`);
|
|
775
1116
|
}
|
|
776
|
-
|
|
1117
|
+
continue; // fallback으로 재시도
|
|
777
1118
|
}
|
|
778
1119
|
// 패딩 검증
|
|
779
1120
|
if (state.padding.length !== charLength) {
|
|
780
1121
|
throw new Error(`[Ddu64 normalizeCharSet] Padding length mismatch. Expected ${charLength}, got ${state.padding.length}`);
|
|
781
1122
|
}
|
|
782
|
-
if (
|
|
1123
|
+
if (charSet.includes(state.padding)) {
|
|
783
1124
|
if (shouldThrow) {
|
|
784
1125
|
throw new Error(`[Ddu64 normalizeCharSet] Padding character "${state.padding}" conflicts with charset.`);
|
|
785
1126
|
}
|
|
786
|
-
|
|
1127
|
+
charSet = charSet.filter((c) => c !== state.padding);
|
|
787
1128
|
}
|
|
788
1129
|
// 불필요한 배열 복사 방지
|
|
789
|
-
const finalSet =
|
|
790
|
-
?
|
|
791
|
-
:
|
|
1130
|
+
const finalSet = charSet.length === requiredLength
|
|
1131
|
+
? charSet
|
|
1132
|
+
: charSet.slice(0, requiredLength);
|
|
792
1133
|
return {
|
|
793
1134
|
charSet: finalSet,
|
|
794
1135
|
padding: state.padding,
|
|
@@ -797,13 +1138,12 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
797
1138
|
};
|
|
798
1139
|
}
|
|
799
1140
|
catch (e) {
|
|
800
|
-
if (shouldThrow
|
|
1141
|
+
if (shouldThrow)
|
|
801
1142
|
throw e;
|
|
802
|
-
|
|
803
|
-
retryCount++;
|
|
1143
|
+
// fallback 시도로 continue
|
|
804
1144
|
}
|
|
805
1145
|
}
|
|
806
|
-
// 최종 fallback
|
|
1146
|
+
// 최종 fallback (이론상 도달 불가, 방어 코드)
|
|
807
1147
|
const fallback = this.getFallbackCharSet(dduOptions);
|
|
808
1148
|
return {
|
|
809
1149
|
charSet: fallback.charSet.slice(0, fallback.requiredLength),
|
|
@@ -909,6 +1249,33 @@ class Ddu64 extends BaseDdu_1.BaseDdu {
|
|
|
909
1249
|
throw new Error(`CharSet with symbol ${symbol} not found`);
|
|
910
1250
|
return cs;
|
|
911
1251
|
}
|
|
1252
|
+
/**
|
|
1253
|
+
* URL-Safe 모드 시 charset/padding이 역변환 대상 문자를 포함하지 않는지 검증합니다.
|
|
1254
|
+
* 역변환 대상 문자("-", "_", ".")가 charset이나 padding에 있으면
|
|
1255
|
+
* fromUrlSafe 시 해당 문자가 "+", "/", "="로 변환되어 데이터가 손상됩니다.
|
|
1256
|
+
*
|
|
1257
|
+
* @returns URL-Safe 모드를 활성화해도 안전한 경우 true
|
|
1258
|
+
*/
|
|
1259
|
+
isUrlSafeCompatible(charSet, paddingChar, shouldThrow) {
|
|
1260
|
+
const conflictChars = Object.keys(URL_SAFE_REVERSE_MAP); // ["-", "_", "."]
|
|
1261
|
+
for (const ch of conflictChars) {
|
|
1262
|
+
for (const c of charSet) {
|
|
1263
|
+
if (c.includes(ch)) {
|
|
1264
|
+
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.`;
|
|
1265
|
+
if (shouldThrow)
|
|
1266
|
+
throw new Error(msg);
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (paddingChar.includes(ch)) {
|
|
1271
|
+
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.`;
|
|
1272
|
+
if (shouldThrow)
|
|
1273
|
+
throw new Error(msg);
|
|
1274
|
+
return false;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
912
1279
|
/**
|
|
913
1280
|
* 커스텀 charset의 조합 중복을 검증합니다.
|
|
914
1281
|
*/
|