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.
@@ -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 { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
16
- const { Base64EncodeTransform, ChunkCollector, BinaryChunkCollector } = require('../lib/streams');
17
-
18
- /**
19
- * Zip a folder into a buffer
20
- * @param {string} folderPath - Path to folder
21
- * @returns {Buffer} Zip buffer
22
- */
23
- function zipFolder(folderPath) {
24
- const zip = new AdmZip();
25
- zip.addLocalFolder(folderPath);
26
- return zip.toBuffer();
27
- }
28
-
29
- /**
30
- * Detect file type from the first 4KB without reading the entire file
31
- * @param {string} filePath - Path to file
32
- * @returns {Promise<object|null>} File type result or null
33
- */
34
- async function detectFileType(filePath) {
35
- try {
36
- const { fileTypeFromBuffer } = await import('file-type');
37
- const fd = await fs.promises.open(filePath, 'r');
38
- const buf = Buffer.alloc(4100);
39
- await fd.read(buf, 0, 4100, 0);
40
- await fd.close();
41
- return await fileTypeFromBuffer(buf);
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- /**
48
- * Compute SHA-256 hash of a file using streaming (no full file in memory)
49
- * @param {string} filePath - Path to file
50
- * @returns {Promise<string>} Hex SHA-256 hash
51
- */
52
- async function computeFileHash(filePath) {
53
- return new Promise((resolve, reject) => {
54
- const hash = crypto.createHash('sha256');
55
- const stream = fs.createReadStream(filePath);
56
- stream.on('data', (chunk) => hash.update(chunk));
57
- stream.on('end', () => resolve(hash.digest('hex')));
58
- stream.on('error', reject);
59
- });
60
- }
61
-
62
- /**
63
- * Encode a file to XLSX/DOCX format with optional AES encryption and compression.
64
- * Uses streaming pipeline for XLSX to support multi-GB files.
65
- * @param {string} inputFile - Path to input file
66
- * @param {object} options - Command options
67
- */
68
- async function encodeCommand(inputFile, options) {
69
- // Check if we should run interactive mode
70
- if (shouldRunInteractive(options, 'encode')) {
71
- const filename = path.basename(inputFile);
72
- console.log(chalk.bold(`\nEncoding: ${filename}`));
73
-
74
- const interactiveOptions = await promptEncodeOptions(filename);
75
- options = { ...options, ...interactiveOptions };
76
- console.log(); // Add spacing before starting
77
- }
78
-
79
- const quiet = options.quiet || false;
80
- const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, text: '' } : ora('Starting encoding process...').start();
81
- const createdFiles = []; // Track created files for cleanup on failure
82
-
83
- // Reset time window for fresh timestamps in this encode session
84
- resetTimeWindow();
85
-
86
- try {
87
- // Validate input exists
88
- if (!fs.existsSync(inputFile)) {
89
- throw new Error(`Path not found: ${inputFile}`);
90
- }
91
-
92
- // Check if input is a directory
93
- const isDirectory = fs.statSync(inputFile).isDirectory();
94
-
95
- // Determine format (default to xlsx)
96
- const format = (options.format || 'xlsx').toLowerCase();
97
- if (format !== 'xlsx' && format !== 'docx') {
98
- throw new Error('Invalid format. Use "xlsx" or "docx".');
99
- }
100
-
101
- const useEncryption = !!options.password;
102
- const chunkInput = (options.chunkSize || '').toString().trim();
103
-
104
- let streamSource;
105
- let filename;
106
- let extension;
107
- let fileSize;
108
- let tempZipPath = null;
109
-
110
- if (isDirectory) {
111
- // Zip the folder to a temp file, then stream from it
112
- spinner.text = 'Zipping folder...';
113
- const folderName = path.basename(inputFile);
114
- const zipBuffer = zipFolder(inputFile);
115
- filename = `${folderName}.zip`;
116
- extension = '.zip';
117
- fileSize = zipBuffer.length;
118
-
119
- // Write zip to temp file for streaming
120
- tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
121
- fs.writeFileSync(tempZipPath, zipBuffer);
122
- streamSource = tempZipPath;
123
-
124
- spinner.succeed && spinner.succeed(`Folder zipped: ${folderName}/ → ${formatBytes(fileSize)}`);
125
- } else {
126
- streamSource = inputFile;
127
- filename = path.basename(inputFile);
128
- extension = path.extname(inputFile);
129
- fileSize = fs.statSync(inputFile).size;
130
- spinner.succeed && spinner.succeed(`File detected: ${filename} (${formatBytes(fileSize)})`);
131
- }
132
-
133
- // Detect file type for compression decision (read only first 4KB)
134
- spinner.text = 'Checking file type...';
135
- let useCompression = true;
136
- const fileType = await detectFileType(streamSource);
137
-
138
- if (fileType && isCompressedMime(fileType.mime)) {
139
- useCompression = false;
140
- spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
141
- }
142
-
143
- // For DOCX format, warn about large files and use legacy in-memory path
144
- if (format === 'docx') {
145
- if (fileSize > 200 * 1024 * 1024) {
146
- spinner.info && spinner.info('Warning: DOCX format is not recommended for files >200MB. Consider using XLSX.');
147
- }
148
- // Use legacy in-memory path for DOCX
149
- await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
150
- if (tempZipPath) cleanupTemp(tempZipPath);
151
- return;
152
- }
153
-
154
- // === XLSX Streaming Pipeline ===
155
- const hash = generateHash();
156
- const outputDir = options.outputDir || process.cwd();
157
-
158
- // Parse chunk size
159
- const chunkInputLower = chunkInput.toLowerCase();
160
- let chunkSizeBytes;
161
-
162
- if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
163
- chunkSizeBytes = Infinity;
164
- } else if (/^\d+\s*parts?$/i.test(chunkInput)) {
165
- const numParts = parseInt(chunkInput, 10);
166
- if (numParts < 1) {
167
- throw new Error('Number of parts must be at least 1');
168
- }
169
- // Estimate output size for part calculation
170
- const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
171
- chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
172
- spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
173
- } else if (chunkInput) {
174
- chunkSizeBytes = parseSizeToBytes(chunkInput);
175
- } else {
176
- chunkSizeBytes = 5 * 1024 * 1024; // 5MB default
177
- }
178
-
179
- // Pre-compute content hash (fast streaming SHA-256 pass)
180
- spinner.text = 'Computing file hash...';
181
- const contentHash = await computeFileHash(streamSource);
182
-
183
- // Generate session salt for encryption (shared across all parts)
184
- const sessionSalt = useEncryption ? generateSalt() : null;
185
-
186
- spinner.text = useCompression ? 'Compressing and encoding...' : 'Encoding...';
187
-
188
- // We don't know totalParts upfront (depends on compression ratio).
189
- // We write each part with partNumber set, and totalParts as null.
190
- // Decode discovers all parts by filename pattern matching (already supported).
191
- const partFiles = [];
192
-
193
- if (useEncryption) {
194
- // Encrypted pipeline: compress → collect binary chunks → encrypt per-part → base64 → xlsx
195
- // Binary chunk size accounts for base64 expansion (~4/3x)
196
- const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
197
-
198
- const onBinaryChunkReady = async (binaryBuffer, index) => {
199
- const partNumber = index + 1;
200
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
201
-
202
- // Encrypt this chunk independently
203
- const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
204
- const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
205
- const authTag = getAuthTag();
206
- const base64Chunk = encrypted.toString('base64');
207
-
208
- const metadata = createMetadata({
209
- originalFilename: filename,
210
- originalExtension: extension,
211
- hash,
212
- partNumber,
213
- totalParts: null,
214
- originalSize: fileSize,
215
- format,
216
- encrypted: true,
217
- compressed: useCompression,
218
- contentHash,
219
- });
220
-
221
- const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
222
- const outputFilename = generateFilename(hash, partNumber, null, format);
223
- const outputPath = path.join(outputDir, outputFilename);
224
-
225
- if (fs.existsSync(outputPath) && !options.force) {
226
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
227
- }
228
-
229
- await createXlsxPartStreaming({
230
- base64Content: base64Chunk,
231
- encryptionMeta,
232
- metadataJson: serializeMetadata(metadata),
233
- outputPath,
234
- });
235
-
236
- partFiles.push(outputPath);
237
- createdFiles.push(outputPath);
238
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
239
- };
240
-
241
- const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
242
-
243
- // Build pipeline
244
- const streams = [fs.createReadStream(streamSource)];
245
- if (useCompression) {
246
- streams.push(createCompressStream());
247
- }
248
- streams.push(collector);
249
-
250
- await pipeline(...streams);
251
- } else {
252
- // Unencrypted pipeline: compress → base64 → collect string chunks → xlsx
253
- const onChunkReady = async (base64Chunk, index) => {
254
- const partNumber = index + 1;
255
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
256
-
257
- const metadata = createMetadata({
258
- originalFilename: filename,
259
- originalExtension: extension,
260
- hash,
261
- partNumber,
262
- totalParts: null,
263
- originalSize: fileSize,
264
- format,
265
- encrypted: false,
266
- compressed: useCompression,
267
- contentHash,
268
- });
269
-
270
- const outputFilename = generateFilename(hash, partNumber, null, format);
271
- const outputPath = path.join(outputDir, outputFilename);
272
-
273
- if (fs.existsSync(outputPath) && !options.force) {
274
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
275
- }
276
-
277
- await createXlsxPartStreaming({
278
- base64Content: base64Chunk,
279
- encryptionMeta: '',
280
- metadataJson: serializeMetadata(metadata),
281
- outputPath,
282
- });
283
-
284
- partFiles.push(outputPath);
285
- createdFiles.push(outputPath);
286
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
287
- };
288
-
289
- const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
290
-
291
- // Build pipeline
292
- const streams = [fs.createReadStream(streamSource)];
293
- if (useCompression) {
294
- streams.push(createCompressStream());
295
- }
296
- streams.push(new Base64EncodeTransform());
297
- streams.push(collector);
298
-
299
- await pipeline(...streams);
300
- }
301
-
302
- const totalParts = partFiles.length;
303
-
304
- spinner.succeed && spinner.succeed('Encoding complete!');
305
-
306
- if (!quiet) {
307
- console.log();
308
- console.log(chalk.green.bold('✓ File encoded successfully!'));
309
- console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
310
- console.log(chalk.cyan(` Hash: ${hash}`));
311
- if (totalParts > 1) {
312
- console.log(chalk.cyan(` Parts: ${totalParts}`));
313
- }
314
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
315
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
316
- console.log(chalk.cyan(` Location: ${outputDir}`));
317
- if (useEncryption) {
318
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
319
- }
320
- }
321
-
322
- if (tempZipPath) cleanupTemp(tempZipPath);
323
- } catch (error) {
324
- spinner.fail && spinner.fail('Encoding failed');
325
-
326
- // Cleanup partially created files
327
- for (const file of createdFiles) {
328
- try {
329
- if (fs.existsSync(file)) {
330
- fs.unlinkSync(file);
331
- }
332
- } catch {
333
- // Ignore cleanup errors
334
- }
335
- }
336
-
337
- console.error(chalk.red(`Error: ${error.message}`));
338
- process.exit(1);
339
- }
340
- }
341
-
342
- /**
343
- * Legacy in-memory encode path for DOCX format
344
- */
345
- async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
346
- const { compress } = require('../lib/compression');
347
- const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
348
- const { generateContentHash } = require('../lib/utils');
349
- const { createDocxWithBase64 } = require('../lib/docx-handler');
350
-
351
- const fileBuffer = fs.readFileSync(inputPath);
352
- const contentHash = generateContentHash(fileBuffer);
353
-
354
- // Compress if beneficial
355
- let processedBuffer = fileBuffer;
356
- if (useCompression) {
357
- spinner.text = 'Compressing...';
358
- const compressedBuffer = await compress(fileBuffer);
359
- if (compressedBuffer.length < fileBuffer.length) {
360
- processedBuffer = compressedBuffer;
361
- spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
362
- } else {
363
- useCompression = false;
364
- spinner.info && spinner.info('Compression skipped (no size benefit)');
365
- }
366
- }
367
-
368
- // Convert to base64
369
- const base64 = processedBuffer.toString('base64');
370
-
371
- let contentToStore;
372
- let encryptionMeta = null;
373
-
374
- if (useEncryption) {
375
- spinner.text = 'Encrypting content...';
376
- const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
377
- encryptionMeta = packMeta({ iv, salt, authTag });
378
- contentToStore = ciphertext;
379
- spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
380
- } else {
381
- contentToStore = base64;
382
- }
383
-
384
- const hash = generateHash();
385
- const outputDir = options.outputDir || process.cwd();
386
- const format = 'docx';
387
-
388
- const metadata = createMetadata({
389
- originalFilename: filename,
390
- originalExtension: extension,
391
- hash,
392
- partNumber: null,
393
- totalParts: null,
394
- originalSize: fileSize,
395
- format,
396
- encrypted: useEncryption,
397
- compressed: useCompression,
398
- contentHash,
399
- });
400
-
401
- const outputFilename = generateFilename(hash, null, null, format);
402
- const outputPath = path.join(outputDir, outputFilename);
403
-
404
- if (fs.existsSync(outputPath) && !options.force) {
405
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
406
- }
407
-
408
- const docxContent = useEncryption
409
- ? `${encryptionMeta}|||${contentToStore}`
410
- : contentToStore;
411
-
412
- await createDocxWithBase64({
413
- base64Content: docxContent,
414
- metadata,
415
- outputPath,
416
- });
417
-
418
- createdFiles.push(outputPath);
419
- spinner.succeed && spinner.succeed('Encoding complete!');
420
-
421
- if (!quiet) {
422
- console.log();
423
- console.log(chalk.green.bold('✓ File encoded successfully!'));
424
- console.log(chalk.cyan(` Format: DOCX`));
425
- console.log(chalk.cyan(` Hash: ${hash}`));
426
- console.log(chalk.cyan(` Output: ${outputFilename}`));
427
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
428
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
429
- console.log(chalk.cyan(` Location: ${outputDir}`));
430
- if (useEncryption) {
431
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
432
- }
433
- }
434
- }
435
-
436
- /**
437
- * Cleanup temporary file
438
- */
439
- function cleanupTemp(tempPath) {
440
- try {
441
- if (fs.existsSync(tempPath)) {
442
- fs.unlinkSync(tempPath);
443
- }
444
- } catch {
445
- // Ignore
446
- }
447
- }
448
-
449
- module.exports = encodeCommand;
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;