@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.
@@ -1,7 +1,8 @@
1
- import { deflateSync, inflateSync } from "zlib";
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
- const shouldThrow = dduOptions?.useBuildErrorReturn ?? false;
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 originalBuffer = typeof input === "string" ? Buffer.from(input, this.encoding) : input;
171
- // 비압축 인코딩
172
- if (!shouldCompress) {
173
- return this.effectiveBitLength <= MAX_FAST_BITS
174
- ? this.encodeFast(originalBuffer, false)
175
- : this.encodeBigInt(originalBuffer, false);
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
- const compressedBuffer = deflateSync(originalBuffer, { level: 9 });
179
- // 압축 효과가 없으면 원본 사용
180
- if (compressedBuffer.length >= originalBuffer.length) {
181
- return this.effectiveBitLength <= MAX_FAST_BITS
182
- ? this.encodeFast(originalBuffer, false)
183
- : this.encodeBigInt(originalBuffer, false);
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
- return this.effectiveBitLength <= MAX_FAST_BITS
186
- ? this.encodeFast(compressedBuffer, true)
187
- : this.encodeBigInt(compressedBuffer, true);
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 { cleanedInput, paddingBits, isCompressed } = this.parseFooter(input);
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
- const decoded = this.effectiveBitLength <= MAX_FAST_BITS
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
- const maxDecompressedBytes = this.normalizeLimit(options?.maxDecompressedBytes, this.defaultMaxDecompressedBytes, true, "maxDecompressedBytes");
219
- return this.inflateWithLimit(decoded, maxDecompressedBytes);
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
- if (maxBytes === Number.POSITIVE_INFINITY)
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 { cleanedInput: input, paddingBits: 0, isCompressed: false };
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
- const firstCode = input.charCodeAt(digitsStart);
365
- if (!isDigitCode(firstCode))
366
- continue;
710
+ // 1) trailing digits 확인
367
711
  let allDigits = true;
368
- for (let i = digitsStart + 1; i < inputLen; i++) {
369
- if (!isDigitCode(input.charCodeAt(i))) {
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 digitSuffix = input.substring(digitsStart);
377
- const paddingBits = parseInt(digitSuffix, 10);
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
- digitSuffix !== paddingBits.toString())
726
+ digitStr !== paddingBits.toString())
382
727
  continue;
383
- // 압축 푸터: pad + marker + digits
384
- if (digitsStart >= padLen + markerLen) {
385
- const markerStart = digitsStart - markerLen;
386
- const padStart = markerStart - padLen;
387
- if (matchesAt(input, COMPRESS_MARKER, markerStart) &&
388
- matchesAt(input, pad, padStart)) {
389
- if (padStart % this.charLength !== 0) {
390
- throw new Error(`[Ddu64 decode] Invalid padding format. Misaligned padding marker`);
391
- }
392
- return {
393
- cleanedInput: input.substring(0, padStart),
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
- // 일반 푸터: pad + digits
400
- const padStart = digitsStart - padLen;
401
- if (padStart >= 0 && matchesAt(input, pad, padStart)) {
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: false,
749
+ isCompressed,
750
+ isEncrypted,
409
751
  };
410
752
  }
411
753
  }
412
- // Fallback: lastIndexOf 기반
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
- let tail = input.substring(tailStart);
420
- const hasMarker = tail.length >= markerLen && matchesAt(tail, COMPRESS_MARKER, 0);
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 { cleanedInput: input, paddingBits: 0, isCompressed: false };
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
- let state = { ...current };
768
- let retryCount = 0;
769
- const maxRetries = 3;
770
- while (retryCount < maxRetries) {
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
- const uniqueChars = Array.from(new Set(state.charSet));
774
- if (uniqueChars.length !== state.charSet.length) {
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 = state.charSet.filter((c, i) => state.charSet.indexOf(c) !== i);
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
- state.charSet = uniqueChars;
1132
+ charSet = uniqueChars;
780
1133
  if (!state.isPredefined)
781
- state.requiredLength = state.charSet.length;
1134
+ requiredLength = charSet.length;
782
1135
  }
783
1136
  // 문자 수 검증
784
- if (state.charSet.length < state.requiredLength) {
785
- throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${state.requiredLength}, Has: ${state.charSet.length}`);
1137
+ if (charSet.length < requiredLength) {
1138
+ throw new Error(`[Ddu64 normalizeCharSet] Insufficient characters. Required: ${requiredLength}, Has: ${charSet.length}`);
786
1139
  }
787
- if (state.requiredLength < 2) {
1140
+ if (requiredLength < 2) {
788
1141
  throw new Error(`[Ddu64 normalizeCharSet] At least 2 unique characters required.`);
789
1142
  }
790
- if (state.charSet.length === 0) {
1143
+ if (charSet.length === 0) {
791
1144
  throw new Error(`[Ddu64 normalizeCharSet] Empty charset.`);
792
1145
  }
793
1146
  // 문자 길이 일관성 검증
794
- const charLength = state.charSet[0].length;
795
- const invalidChar = state.charSet.find((c) => c.length !== charLength);
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
- throw new Error("internal retry");
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 (state.charSet.includes(state.padding)) {
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
- state.charSet = state.charSet.filter((c) => c !== state.padding);
1163
+ charSet = charSet.filter((c) => c !== state.padding);
811
1164
  }
812
1165
  // 불필요한 배열 복사 방지
813
- const finalSet = state.charSet.length === state.requiredLength
814
- ? state.charSet
815
- : state.charSet.slice(0, state.requiredLength);
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 && !e.message.includes("internal retry"))
1177
+ if (shouldThrow)
825
1178
  throw e;
826
- state = this.getFallbackCharSet(dduOptions);
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
  */