stegdoc 4.0.0 → 5.0.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/LICENSE +21 -21
- package/README.md +200 -214
- package/package.json +59 -59
- package/src/commands/decode.js +485 -343
- package/src/commands/encode.js +567 -449
- package/src/commands/info.js +118 -114
- package/src/commands/verify.js +207 -204
- package/src/index.js +89 -87
- package/src/lib/compression.js +177 -115
- package/src/lib/crypto.js +172 -172
- package/src/lib/decoy-generator.js +306 -306
- package/src/lib/docx-handler.js +587 -161
- package/src/lib/docx-templates.js +355 -0
- package/src/lib/file-handler.js +113 -113
- package/src/lib/file-utils.js +160 -150
- package/src/lib/interactive.js +190 -190
- package/src/lib/log-generator.js +764 -0
- package/src/lib/metadata.js +151 -122
- package/src/lib/streams.js +197 -197
- package/src/lib/utils.js +227 -227
- package/src/lib/xlsx-handler.js +597 -416
- package/src/lib/xml-utils.js +115 -115
package/src/commands/encode.js
CHANGED
|
@@ -1,449 +1,567 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const { pipeline } = require('stream/promises');
|
|
4
|
-
const chalk = require('chalk');
|
|
5
|
-
const ora = require('ora');
|
|
6
|
-
const AdmZip = require('adm-zip');
|
|
7
|
-
const { createDocxWithBase64 } = require('../lib/docx-handler');
|
|
8
|
-
const { createXlsxPartStreaming } = require('../lib/xlsx-handler');
|
|
9
|
-
const { createMetadata, serializeMetadata } = require('../lib/metadata');
|
|
10
|
-
const crypto = require('crypto');
|
|
11
|
-
const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
|
|
12
|
-
const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
|
|
13
|
-
const { isCompressedMime, createCompressStream } = require('../lib/compression');
|
|
14
|
-
const { resetTimeWindow } = require('../lib/decoy-generator');
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
zip.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
spinner.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
filename =
|
|
116
|
-
extension =
|
|
117
|
-
fileSize =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
chunkSizeBytes =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { pipeline } = require('stream/promises');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const AdmZip = require('adm-zip');
|
|
7
|
+
const { createDocxWithBase64, createDocxV5 } = require('../lib/docx-handler');
|
|
8
|
+
const { createXlsxPartStreaming, createXlsxPartV5 } = require('../lib/xlsx-handler');
|
|
9
|
+
const { createMetadata, serializeMetadata } = require('../lib/metadata');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
|
|
12
|
+
const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
|
|
13
|
+
const { isCompressedMime, createCompressStream, createBrotliCompressStream } = require('../lib/compression');
|
|
14
|
+
const { resetTimeWindow } = require('../lib/decoy-generator');
|
|
15
|
+
const { resetTimeState, BYTES_PER_DATA_LINE, calculateDataLineCount } = require('../lib/log-generator');
|
|
16
|
+
const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
|
|
17
|
+
const { Base64EncodeTransform, ChunkCollector, BinaryChunkCollector } = require('../lib/streams');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Zip a folder into a buffer
|
|
21
|
+
*/
|
|
22
|
+
function zipFolder(folderPath) {
|
|
23
|
+
const zip = new AdmZip();
|
|
24
|
+
zip.addLocalFolder(folderPath);
|
|
25
|
+
return zip.toBuffer();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect file type from the first 4KB
|
|
30
|
+
*/
|
|
31
|
+
async function detectFileType(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
34
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
35
|
+
const buf = Buffer.alloc(4100);
|
|
36
|
+
await fd.read(buf, 0, 4100, 0);
|
|
37
|
+
await fd.close();
|
|
38
|
+
return await fileTypeFromBuffer(buf);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute SHA-256 hash of a file using streaming
|
|
46
|
+
*/
|
|
47
|
+
async function computeFileHash(filePath) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const hash = crypto.createHash('sha256');
|
|
50
|
+
const stream = fs.createReadStream(filePath);
|
|
51
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
52
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
53
|
+
stream.on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Encode a file to XLSX/DOCX format with optional AES encryption and compression.
|
|
59
|
+
*/
|
|
60
|
+
async function encodeCommand(inputFile, options) {
|
|
61
|
+
// Check if we should run interactive mode
|
|
62
|
+
if (shouldRunInteractive(options, 'encode')) {
|
|
63
|
+
const filename = path.basename(inputFile);
|
|
64
|
+
console.log(chalk.bold(`\nEncoding: ${filename}`));
|
|
65
|
+
|
|
66
|
+
const interactiveOptions = await promptEncodeOptions(filename);
|
|
67
|
+
options = { ...options, ...interactiveOptions };
|
|
68
|
+
console.log();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const quiet = options.quiet || false;
|
|
72
|
+
const legacy = options.legacy || false;
|
|
73
|
+
const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, text: '' } : ora('Starting encoding process...').start();
|
|
74
|
+
const createdFiles = [];
|
|
75
|
+
|
|
76
|
+
// Reset time windows
|
|
77
|
+
resetTimeWindow();
|
|
78
|
+
resetTimeState();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
if (!fs.existsSync(inputFile)) {
|
|
82
|
+
throw new Error(`Path not found: ${inputFile}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isDirectory = fs.statSync(inputFile).isDirectory();
|
|
86
|
+
const format = (options.format || 'xlsx').toLowerCase();
|
|
87
|
+
if (format !== 'xlsx' && format !== 'docx') {
|
|
88
|
+
throw new Error('Invalid format. Use "xlsx" or "docx".');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const useEncryption = !!options.password;
|
|
92
|
+
const chunkInput = (options.chunkSize || '').toString().trim();
|
|
93
|
+
|
|
94
|
+
let streamSource;
|
|
95
|
+
let filename;
|
|
96
|
+
let extension;
|
|
97
|
+
let fileSize;
|
|
98
|
+
let tempZipPath = null;
|
|
99
|
+
|
|
100
|
+
if (isDirectory) {
|
|
101
|
+
spinner.text = 'Zipping folder...';
|
|
102
|
+
const folderName = path.basename(inputFile);
|
|
103
|
+
const zipBuffer = zipFolder(inputFile);
|
|
104
|
+
filename = `${folderName}.zip`;
|
|
105
|
+
extension = '.zip';
|
|
106
|
+
fileSize = zipBuffer.length;
|
|
107
|
+
|
|
108
|
+
tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
|
|
109
|
+
fs.writeFileSync(tempZipPath, zipBuffer);
|
|
110
|
+
streamSource = tempZipPath;
|
|
111
|
+
|
|
112
|
+
spinner.succeed && spinner.succeed(`Folder zipped: ${folderName}/ → ${formatBytes(fileSize)}`);
|
|
113
|
+
} else {
|
|
114
|
+
streamSource = inputFile;
|
|
115
|
+
filename = path.basename(inputFile);
|
|
116
|
+
extension = path.extname(inputFile);
|
|
117
|
+
fileSize = fs.statSync(inputFile).size;
|
|
118
|
+
spinner.succeed && spinner.succeed(`File detected: ${filename} (${formatBytes(fileSize)})`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
spinner.text = 'Checking file type...';
|
|
122
|
+
let useCompression = true;
|
|
123
|
+
const fileType = await detectFileType(streamSource);
|
|
124
|
+
|
|
125
|
+
if (fileType && isCompressedMime(fileType.mime)) {
|
|
126
|
+
useCompression = false;
|
|
127
|
+
spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// DOCX v5 size limit (not applicable in legacy mode or with --no-limit)
|
|
131
|
+
const noLimit = options.noLimit || options.limit === false;
|
|
132
|
+
if (format === 'docx' && !legacy && !noLimit && fileSize > 1 * 1024 * 1024) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`DOCX format is limited to files under 1 MB (yours is ${formatBytes(fileSize)}). ` +
|
|
135
|
+
`Use XLSX format (-f xlsx) for larger files, or --no-limit to bypass.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Route to legacy or v5 pipeline
|
|
140
|
+
if (legacy) {
|
|
141
|
+
if (format === 'docx') {
|
|
142
|
+
await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
|
|
143
|
+
} else {
|
|
144
|
+
await encodeLegacyXlsx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
|
|
145
|
+
}
|
|
146
|
+
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// === v5 Log-Embed Pipeline ===
|
|
151
|
+
const hash = generateHash();
|
|
152
|
+
const outputDir = options.outputDir || process.cwd();
|
|
153
|
+
|
|
154
|
+
// Parse chunk size
|
|
155
|
+
const chunkInputLower = chunkInput.toLowerCase();
|
|
156
|
+
let chunkSizeBytes;
|
|
157
|
+
|
|
158
|
+
if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
|
|
159
|
+
chunkSizeBytes = Infinity;
|
|
160
|
+
} else if (/^\d+\s*parts?$/i.test(chunkInput)) {
|
|
161
|
+
const numParts = parseInt(chunkInput, 10);
|
|
162
|
+
if (numParts < 1) {
|
|
163
|
+
throw new Error('Number of parts must be at least 1');
|
|
164
|
+
}
|
|
165
|
+
const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
|
|
166
|
+
chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
|
|
167
|
+
spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
|
|
168
|
+
} else if (chunkInput) {
|
|
169
|
+
chunkSizeBytes = parseSizeToBytes(chunkInput);
|
|
170
|
+
} else {
|
|
171
|
+
chunkSizeBytes = 5 * 1024 * 1024; // 5MB default
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pre-compute content hash
|
|
175
|
+
spinner.text = 'Computing file hash...';
|
|
176
|
+
const contentHash = await computeFileHash(streamSource);
|
|
177
|
+
|
|
178
|
+
// Generate session salt for encryption
|
|
179
|
+
const sessionSalt = useEncryption ? generateSalt() : null;
|
|
180
|
+
|
|
181
|
+
spinner.text = useCompression ? 'Compressing (Brotli) and encoding...' : 'Encoding...';
|
|
182
|
+
|
|
183
|
+
const partFiles = [];
|
|
184
|
+
|
|
185
|
+
// v5 pipeline: compress (brotli) → collect binary chunks → encrypt per-part → embed in log lines
|
|
186
|
+
const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
|
|
187
|
+
|
|
188
|
+
const onBinaryChunkReady = async (binaryBuffer, index) => {
|
|
189
|
+
const partNumber = index + 1;
|
|
190
|
+
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
191
|
+
|
|
192
|
+
let payloadBuffer;
|
|
193
|
+
let encryptionMeta = '';
|
|
194
|
+
|
|
195
|
+
if (useEncryption) {
|
|
196
|
+
const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
|
|
197
|
+
payloadBuffer = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
|
|
198
|
+
const authTag = getAuthTag();
|
|
199
|
+
encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
|
|
200
|
+
} else {
|
|
201
|
+
payloadBuffer = binaryBuffer;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const dataLineCount = calculateDataLineCount(payloadBuffer.length);
|
|
205
|
+
|
|
206
|
+
const metadata = createMetadata({
|
|
207
|
+
originalFilename: filename,
|
|
208
|
+
originalExtension: extension,
|
|
209
|
+
hash,
|
|
210
|
+
partNumber,
|
|
211
|
+
totalParts: null,
|
|
212
|
+
originalSize: fileSize,
|
|
213
|
+
format,
|
|
214
|
+
encrypted: useEncryption,
|
|
215
|
+
compressed: useCompression,
|
|
216
|
+
contentHash,
|
|
217
|
+
stegoMethod: 'log-embed',
|
|
218
|
+
compressionAlgo: 'brotli',
|
|
219
|
+
payloadSize: payloadBuffer.length,
|
|
220
|
+
dataLineCount,
|
|
221
|
+
headerLineCount: null,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const metadataJson = serializeMetadata(metadata);
|
|
225
|
+
|
|
226
|
+
// Calculate header line count with actual metadata
|
|
227
|
+
const actualHeaderPayload = Buffer.from(`STGD05|${Buffer.from(metadataJson).length}|${Buffer.from(encryptionMeta).length}|${metadataJson}${encryptionMeta}`);
|
|
228
|
+
const actualHeaderLineCount = Math.ceil(actualHeaderPayload.length / BYTES_PER_DATA_LINE);
|
|
229
|
+
metadata.headerLineCount = actualHeaderLineCount;
|
|
230
|
+
|
|
231
|
+
// Verify stability
|
|
232
|
+
const finalMetadataJson = serializeMetadata(metadata);
|
|
233
|
+
const verifyPayload = Buffer.from(`STGD05|${Buffer.from(finalMetadataJson).length}|${Buffer.from(encryptionMeta).length}|${finalMetadataJson}${encryptionMeta}`);
|
|
234
|
+
const verifyCount = Math.ceil(verifyPayload.length / BYTES_PER_DATA_LINE);
|
|
235
|
+
if (verifyCount !== actualHeaderLineCount) {
|
|
236
|
+
metadata.headerLineCount = verifyCount;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const outputFilename = generateFilename(hash, partNumber, null, format);
|
|
240
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
241
|
+
|
|
242
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
243
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (format === 'docx') {
|
|
247
|
+
await createDocxV5({
|
|
248
|
+
payloadBuffer,
|
|
249
|
+
encryptionMeta,
|
|
250
|
+
metadataJson: serializeMetadata(metadata),
|
|
251
|
+
outputPath,
|
|
252
|
+
hash,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
await createXlsxPartV5({
|
|
256
|
+
payloadBuffer,
|
|
257
|
+
encryptionMeta,
|
|
258
|
+
metadataJson: serializeMetadata(metadata),
|
|
259
|
+
outputPath,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
partFiles.push(outputPath);
|
|
264
|
+
createdFiles.push(outputPath);
|
|
265
|
+
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(payloadBuffer.length)} payload, ${dataLineCount} data lines)`);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
|
|
269
|
+
|
|
270
|
+
const streams = [fs.createReadStream(streamSource)];
|
|
271
|
+
if (useCompression) {
|
|
272
|
+
streams.push(createBrotliCompressStream());
|
|
273
|
+
}
|
|
274
|
+
streams.push(collector);
|
|
275
|
+
|
|
276
|
+
await pipeline(...streams);
|
|
277
|
+
|
|
278
|
+
const totalParts = partFiles.length;
|
|
279
|
+
|
|
280
|
+
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
281
|
+
|
|
282
|
+
if (!quiet) {
|
|
283
|
+
console.log();
|
|
284
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
285
|
+
console.log(chalk.cyan(` Format: ${format.toUpperCase()} (v5 log-embed)`));
|
|
286
|
+
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
287
|
+
if (totalParts > 1) {
|
|
288
|
+
console.log(chalk.cyan(` Parts: ${totalParts}`));
|
|
289
|
+
}
|
|
290
|
+
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
291
|
+
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes (Brotli)' : 'No'}`));
|
|
292
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
293
|
+
if (useEncryption) {
|
|
294
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
spinner.fail && spinner.fail('Encoding failed');
|
|
301
|
+
|
|
302
|
+
for (const file of createdFiles) {
|
|
303
|
+
try {
|
|
304
|
+
if (fs.existsSync(file)) {
|
|
305
|
+
fs.unlinkSync(file);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// Ignore cleanup errors
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Legacy v4 XLSX Pipeline ────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
async function encodeLegacyXlsx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
|
|
320
|
+
const hash = generateHash();
|
|
321
|
+
const outputDir = options.outputDir || process.cwd();
|
|
322
|
+
const format = 'xlsx';
|
|
323
|
+
const chunkInput = (options.chunkSize || '').toString().trim();
|
|
324
|
+
|
|
325
|
+
// Parse chunk size
|
|
326
|
+
const chunkInputLower = chunkInput.toLowerCase();
|
|
327
|
+
let chunkSizeBytes;
|
|
328
|
+
|
|
329
|
+
if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
|
|
330
|
+
chunkSizeBytes = Infinity;
|
|
331
|
+
} else if (/^\d+\s*parts?$/i.test(chunkInput)) {
|
|
332
|
+
const numParts = parseInt(chunkInput, 10);
|
|
333
|
+
if (numParts < 1) throw new Error('Number of parts must be at least 1');
|
|
334
|
+
const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
|
|
335
|
+
chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
|
|
336
|
+
spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
|
|
337
|
+
} else if (chunkInput) {
|
|
338
|
+
chunkSizeBytes = parseSizeToBytes(chunkInput);
|
|
339
|
+
} else {
|
|
340
|
+
chunkSizeBytes = 5 * 1024 * 1024;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
spinner.text = 'Computing file hash...';
|
|
344
|
+
const contentHash = await computeFileHash(inputPath);
|
|
345
|
+
|
|
346
|
+
const sessionSalt = useEncryption ? generateSalt() : null;
|
|
347
|
+
|
|
348
|
+
spinner.text = useCompression ? 'Compressing and encoding (legacy v4)...' : 'Encoding (legacy v4)...';
|
|
349
|
+
|
|
350
|
+
const partFiles = [];
|
|
351
|
+
|
|
352
|
+
if (useEncryption) {
|
|
353
|
+
const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
|
|
354
|
+
|
|
355
|
+
const onBinaryChunkReady = async (binaryBuffer, index) => {
|
|
356
|
+
const partNumber = index + 1;
|
|
357
|
+
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
358
|
+
|
|
359
|
+
const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
|
|
360
|
+
const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
|
|
361
|
+
const authTag = getAuthTag();
|
|
362
|
+
const base64Chunk = encrypted.toString('base64');
|
|
363
|
+
|
|
364
|
+
const metadata = createMetadata({
|
|
365
|
+
originalFilename: filename,
|
|
366
|
+
originalExtension: extension,
|
|
367
|
+
hash,
|
|
368
|
+
partNumber,
|
|
369
|
+
totalParts: null,
|
|
370
|
+
originalSize: fileSize,
|
|
371
|
+
format,
|
|
372
|
+
encrypted: true,
|
|
373
|
+
compressed: useCompression,
|
|
374
|
+
contentHash,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
|
|
378
|
+
const outputFilename = generateFilename(hash, partNumber, null, format);
|
|
379
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
380
|
+
|
|
381
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
382
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
await createXlsxPartStreaming({
|
|
386
|
+
base64Content: base64Chunk,
|
|
387
|
+
encryptionMeta,
|
|
388
|
+
metadataJson: serializeMetadata(metadata),
|
|
389
|
+
outputPath,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
partFiles.push(outputPath);
|
|
393
|
+
createdFiles.push(outputPath);
|
|
394
|
+
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
|
|
398
|
+
const streams = [fs.createReadStream(inputPath)];
|
|
399
|
+
if (useCompression) streams.push(createCompressStream());
|
|
400
|
+
streams.push(collector);
|
|
401
|
+
await pipeline(...streams);
|
|
402
|
+
} else {
|
|
403
|
+
const onChunkReady = async (base64Chunk, index) => {
|
|
404
|
+
const partNumber = index + 1;
|
|
405
|
+
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
406
|
+
|
|
407
|
+
const metadata = createMetadata({
|
|
408
|
+
originalFilename: filename,
|
|
409
|
+
originalExtension: extension,
|
|
410
|
+
hash,
|
|
411
|
+
partNumber,
|
|
412
|
+
totalParts: null,
|
|
413
|
+
originalSize: fileSize,
|
|
414
|
+
format,
|
|
415
|
+
encrypted: false,
|
|
416
|
+
compressed: useCompression,
|
|
417
|
+
contentHash,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const outputFilename = generateFilename(hash, partNumber, null, format);
|
|
421
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
422
|
+
|
|
423
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
424
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await createXlsxPartStreaming({
|
|
428
|
+
base64Content: base64Chunk,
|
|
429
|
+
encryptionMeta: '',
|
|
430
|
+
metadataJson: serializeMetadata(metadata),
|
|
431
|
+
outputPath,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
partFiles.push(outputPath);
|
|
435
|
+
createdFiles.push(outputPath);
|
|
436
|
+
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
|
|
440
|
+
const streams = [fs.createReadStream(inputPath)];
|
|
441
|
+
if (useCompression) streams.push(createCompressStream());
|
|
442
|
+
streams.push(new Base64EncodeTransform());
|
|
443
|
+
streams.push(collector);
|
|
444
|
+
await pipeline(...streams);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const totalParts = partFiles.length;
|
|
448
|
+
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
449
|
+
|
|
450
|
+
if (!quiet) {
|
|
451
|
+
console.log();
|
|
452
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
453
|
+
console.log(chalk.cyan(` Format: XLSX (v4 legacy)`));
|
|
454
|
+
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
455
|
+
if (totalParts > 1) {
|
|
456
|
+
console.log(chalk.cyan(` Parts: ${totalParts}`));
|
|
457
|
+
}
|
|
458
|
+
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
459
|
+
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes (gzip)' : 'No'}`));
|
|
460
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
461
|
+
if (useEncryption) {
|
|
462
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Legacy v4 DOCX Pipeline ────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
|
|
470
|
+
const { compress } = require('../lib/compression');
|
|
471
|
+
const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
|
|
472
|
+
const { generateContentHash } = require('../lib/utils');
|
|
473
|
+
|
|
474
|
+
const fileBuffer = fs.readFileSync(inputPath);
|
|
475
|
+
const contentHash = generateContentHash(fileBuffer);
|
|
476
|
+
|
|
477
|
+
let processedBuffer = fileBuffer;
|
|
478
|
+
if (useCompression) {
|
|
479
|
+
spinner.text = 'Compressing...';
|
|
480
|
+
const compressedBuffer = await compress(fileBuffer);
|
|
481
|
+
if (compressedBuffer.length < fileBuffer.length) {
|
|
482
|
+
processedBuffer = compressedBuffer;
|
|
483
|
+
spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
|
|
484
|
+
} else {
|
|
485
|
+
useCompression = false;
|
|
486
|
+
spinner.info && spinner.info('Compression skipped (no size benefit)');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const base64 = processedBuffer.toString('base64');
|
|
491
|
+
|
|
492
|
+
let contentToStore;
|
|
493
|
+
let encryptionMeta = null;
|
|
494
|
+
|
|
495
|
+
if (useEncryption) {
|
|
496
|
+
spinner.text = 'Encrypting content...';
|
|
497
|
+
const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
|
|
498
|
+
encryptionMeta = packMeta({ iv, salt, authTag });
|
|
499
|
+
contentToStore = ciphertext;
|
|
500
|
+
spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
|
|
501
|
+
} else {
|
|
502
|
+
contentToStore = base64;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const hash = generateHash();
|
|
506
|
+
const outputDir = options.outputDir || process.cwd();
|
|
507
|
+
const format = 'docx';
|
|
508
|
+
|
|
509
|
+
const metadata = createMetadata({
|
|
510
|
+
originalFilename: filename,
|
|
511
|
+
originalExtension: extension,
|
|
512
|
+
hash,
|
|
513
|
+
partNumber: null,
|
|
514
|
+
totalParts: null,
|
|
515
|
+
originalSize: fileSize,
|
|
516
|
+
format,
|
|
517
|
+
encrypted: useEncryption,
|
|
518
|
+
compressed: useCompression,
|
|
519
|
+
contentHash,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const outputFilename = generateFilename(hash, null, null, format);
|
|
523
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
524
|
+
|
|
525
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
526
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const docxContent = useEncryption
|
|
530
|
+
? `${encryptionMeta}|||${contentToStore}`
|
|
531
|
+
: contentToStore;
|
|
532
|
+
|
|
533
|
+
await createDocxWithBase64({
|
|
534
|
+
base64Content: docxContent,
|
|
535
|
+
metadata,
|
|
536
|
+
outputPath,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
createdFiles.push(outputPath);
|
|
540
|
+
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
541
|
+
|
|
542
|
+
if (!quiet) {
|
|
543
|
+
console.log();
|
|
544
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
545
|
+
console.log(chalk.cyan(` Format: DOCX (v4 legacy)`));
|
|
546
|
+
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
547
|
+
console.log(chalk.cyan(` Output: ${outputFilename}`));
|
|
548
|
+
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
549
|
+
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
550
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
551
|
+
if (useEncryption) {
|
|
552
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function cleanupTemp(tempPath) {
|
|
558
|
+
try {
|
|
559
|
+
if (fs.existsSync(tempPath)) {
|
|
560
|
+
fs.unlinkSync(tempPath);
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// Ignore
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
module.exports = encodeCommand;
|