@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.
@@ -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
- const shouldThrow = dduOptions?.useBuildErrorReturn ?? false;
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 originalBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
147
- // 비압축 인코딩
148
- if (!shouldCompress) {
149
- return this.effectiveBitLength <= MAX_FAST_BITS
150
- ? this.encodeFast(originalBuffer, false)
151
- : this.encodeBigInt(originalBuffer, false);
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
- const compressedBuffer = (0, zlib_1.deflateSync)(originalBuffer, { level: 9 });
155
- // 압축 효과가 없으면 원본 사용
156
- if (compressedBuffer.length >= originalBuffer.length) {
157
- return this.effectiveBitLength <= MAX_FAST_BITS
158
- ? this.encodeFast(originalBuffer, false)
159
- : this.encodeBigInt(originalBuffer, false);
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
- return this.effectiveBitLength <= MAX_FAST_BITS
162
- ? this.encodeFast(compressedBuffer, true)
163
- : this.encodeBigInt(compressedBuffer, true);
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 { cleanedInput, paddingBits, isCompressed } = this.parseFooter(input);
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
- const decoded = this.effectiveBitLength <= MAX_FAST_BITS
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
- const maxDecompressedBytes = this.normalizeLimit(options?.maxDecompressedBytes, this.defaultMaxDecompressedBytes, true, "maxDecompressedBytes");
195
- return this.inflateWithLimit(decoded, maxDecompressedBytes);
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
- if (maxBytes === Number.POSITIVE_INFINITY)
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 { cleanedInput: input, paddingBits: 0, isCompressed: false };
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
- const firstCode = input.charCodeAt(digitsStart);
341
- if (!isDigitCode(firstCode))
342
- continue;
674
+ // 1) trailing digits 확인
343
675
  let allDigits = true;
344
- for (let i = digitsStart + 1; i < inputLen; i++) {
345
- if (!isDigitCode(input.charCodeAt(i))) {
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 digitSuffix = input.substring(digitsStart);
353
- const paddingBits = parseInt(digitSuffix, 10);
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
- digitSuffix !== paddingBits.toString())
690
+ digitStr !== paddingBits.toString())
358
691
  continue;
359
- // 압축 푸터: pad + marker + digits
360
- if (digitsStart >= padLen + markerLen) {
361
- const markerStart = digitsStart - markerLen;
362
- const padStart = markerStart - padLen;
363
- if (matchesAt(input, COMPRESS_MARKER, markerStart) &&
364
- matchesAt(input, pad, padStart)) {
365
- if (padStart % this.charLength !== 0) {
366
- throw new Error(`[Ddu64 decode] Invalid padding format. Misaligned padding marker`);
367
- }
368
- return {
369
- cleanedInput: input.substring(0, padStart),
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
- // 일반 푸터: pad + digits
376
- const padStart = digitsStart - padLen;
377
- if (padStart >= 0 && matchesAt(input, pad, padStart)) {
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: false,
713
+ isCompressed,
714
+ isEncrypted,
385
715
  };
386
716
  }
387
717
  }
388
- // Fallback: lastIndexOf 기반
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
- let tail = input.substring(tailStart);
396
- const hasMarker = tail.length >= markerLen && matchesAt(tail, COMPRESS_MARKER, 0);
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 { cleanedInput: input, paddingBits: 0, isCompressed: false };
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
- let state = { ...current };
744
- let retryCount = 0;
745
- const maxRetries = 3;
746
- while (retryCount < maxRetries) {
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
- const uniqueChars = Array.from(new Set(state.charSet));
750
- if (uniqueChars.length !== state.charSet.length) {
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 = state.charSet.filter((c, i) => state.charSet.indexOf(c) !== i);
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
- state.charSet = uniqueChars;
1096
+ charSet = uniqueChars;
756
1097
  if (!state.isPredefined)
757
- state.requiredLength = state.charSet.length;
1098
+ requiredLength = charSet.length;
758
1099
  }
759
1100
  // 문자 수 검증
760
- if (state.charSet.length < state.requiredLength) {
761
- throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${state.requiredLength}, Has: ${state.charSet.length}`);
1101
+ if (charSet.length < requiredLength) {
1102
+ throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${requiredLength}, Has: ${charSet.length}`);
762
1103
  }
763
- if (state.requiredLength < 2) {
1104
+ if (requiredLength < 2) {
764
1105
  throw new Error(`[Ddu64 normalizeCharSet] At least 2 unique characters required.`);
765
1106
  }
766
- if (state.charSet.length === 0) {
1107
+ if (charSet.length === 0) {
767
1108
  throw new Error(`[Ddu64 normalizeCharSet] Empty charset.`);
768
1109
  }
769
1110
  // 문자 길이 일관성 검증
770
- const charLength = state.charSet[0].length;
771
- const invalidChar = state.charSet.find((c) => c.length !== charLength);
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
- throw new Error("internal retry");
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 (state.charSet.includes(state.padding)) {
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
- state.charSet = state.charSet.filter((c) => c !== state.padding);
1127
+ charSet = charSet.filter((c) => c !== state.padding);
787
1128
  }
788
1129
  // 불필요한 배열 복사 방지
789
- const finalSet = state.charSet.length === state.requiredLength
790
- ? state.charSet
791
- : state.charSet.slice(0, state.requiredLength);
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 && !e.message.includes("internal retry"))
1141
+ if (shouldThrow)
801
1142
  throw e;
802
- state = this.getFallbackCharSet(dduOptions);
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
  */