stegdoc 3.0.2 → 5.0.0

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,215 +1,485 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const chalk = require('chalk');
4
- const ora = require('ora');
5
- const { readDocxBase64 } = require('../lib/docx-handler');
6
- const { readXlsxBase64 } = require('../lib/xlsx-handler');
7
- const { validateMetadata, isMultiPart } = require('../lib/metadata');
8
- const { detectFormat, formatBytes, generateContentHash } = require('../lib/utils');
9
- const { decrypt, unpackEncryptionMeta } = require('../lib/crypto');
10
- const { decompress } = require('../lib/compression');
11
- const { promptPassword, promptOverwrite } = require('../lib/interactive');
12
- const { extractContent, findMultiPartFiles, mergeBase64Chunks } = require('../lib/file-utils');
13
-
14
- /**
15
- * Read file based on format
16
- * @param {string} filePath - Path to file
17
- * @param {string} format - File format
18
- * @returns {Promise<object>} Read result
19
- */
20
- async function readFile(filePath, format) {
21
- if (format === 'xlsx') {
22
- return await readXlsxBase64(filePath);
23
- } else {
24
- return await readDocxBase64(filePath);
25
- }
26
- }
27
-
28
- /**
29
- * Decode a DOCX/XLSX file back to original format
30
- * @param {string} inputFile - Path to input file
31
- * @param {object} options - Command options
32
- * @param {string} options.output - Output file path
33
- * @param {string} options.password - Decryption password
34
- * @param {boolean} options.force - Overwrite existing files without asking
35
- * @param {boolean} options.quiet - Minimal output
36
- */
37
- async function decodeCommand(inputFile, options) {
38
- const quiet = options.quiet || false;
39
- const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, warn: () => {}, text: '' } : ora('Starting decoding process...').start();
40
-
41
- try {
42
- // Detect format from extension
43
- const format = detectFormat(inputFile);
44
- if (!format) {
45
- throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
46
- }
47
-
48
- spinner.text = `Reading ${format.toUpperCase()} file...`;
49
-
50
- // Read the first file
51
- const readResult = await readFile(inputFile, format);
52
- const { encryptedContent, encryptionMeta, metadata } = extractContent(readResult, format);
53
-
54
- // Validate metadata
55
- validateMetadata(metadata);
56
-
57
- const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
58
- const isCompressed = metadata.compressed || false;
59
-
60
- spinner.succeed && spinner.succeed(`${format.toUpperCase()} file read successfully`);
61
-
62
- if (!quiet) {
63
- console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
64
- console.log(chalk.cyan(` Original size: ${formatBytes(metadata.originalSize)}`));
65
- console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
66
- console.log(chalk.cyan(` Compressed: ${isCompressed ? 'Yes' : 'No'}`));
67
- }
68
-
69
- // Check password for encrypted files - prompt if not provided and not in quiet mode
70
- if (isEncrypted && !options.password) {
71
- if (quiet || options.yes) {
72
- throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
73
- }
74
- // Prompt for password interactively
75
- options.password = await promptPassword();
76
- }
77
-
78
- // Determine output path and check overwrite
79
- let outputPath;
80
- if (options.output) {
81
- // Check if output is a directory - if so, append the original filename
82
- if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
83
- outputPath = path.join(options.output, metadata.originalFilename);
84
- } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
85
- // No extension and doesn't exist - treat as directory, create it
86
- fs.mkdirSync(options.output, { recursive: true });
87
- outputPath = path.join(options.output, metadata.originalFilename);
88
- } else {
89
- outputPath = options.output;
90
- }
91
- } else {
92
- outputPath = path.join(process.cwd(), metadata.originalFilename);
93
- }
94
-
95
- if (fs.existsSync(outputPath) && !options.force) {
96
- if (quiet || options.yes) {
97
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
98
- }
99
- // Prompt for overwrite confirmation
100
- const shouldOverwrite = await promptOverwrite(outputPath);
101
- if (!shouldOverwrite) {
102
- console.log(chalk.yellow('Operation cancelled.'));
103
- process.exit(0);
104
- }
105
- }
106
-
107
- // Check if multi-part
108
- let finalBase64;
109
-
110
- if (isMultiPart(metadata)) {
111
- spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
112
-
113
- // Find all parts
114
- const inputDir = path.dirname(inputFile);
115
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format, metadata.totalParts);
116
-
117
- if (allParts.length !== metadata.totalParts) {
118
- throw new Error(
119
- `Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
120
- `Make sure all parts are in the same directory.`
121
- );
122
- }
123
-
124
- spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
125
-
126
- // Read all parts
127
- const chunks = [];
128
-
129
- for (let i = 0; i < allParts.length; i++) {
130
- const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
131
- const partResult = await readFile(allParts[i].path, format);
132
- const { encryptedContent: partContent } = extractContent(partResult, format);
133
- chunks.push(partContent);
134
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
135
- }
136
-
137
- // Merge chunks
138
- spinner.text = 'Merging parts...';
139
- const mergedContent = mergeBase64Chunks(chunks);
140
- spinner.succeed && spinner.succeed('Parts merged successfully');
141
-
142
- // Decrypt if needed
143
- if (isEncrypted) {
144
- spinner.text = 'Decrypting content...';
145
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
146
- finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
147
- spinner.succeed && spinner.succeed('Content decrypted');
148
- } else {
149
- finalBase64 = mergedContent;
150
- }
151
- } else {
152
- // Single file - Decrypt if needed
153
- if (isEncrypted) {
154
- spinner.text = 'Decrypting content...';
155
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
156
- finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
157
- spinner.succeed && spinner.succeed('Content decrypted');
158
- } else {
159
- finalBase64 = encryptedContent;
160
- }
161
- }
162
-
163
- // Decompress if needed
164
- let fileBuffer;
165
- if (isCompressed) {
166
- spinner.text = 'Decompressing...';
167
- const compressedBuffer = Buffer.from(finalBase64, 'base64');
168
- fileBuffer = await decompress(compressedBuffer);
169
- spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
170
- } else {
171
- fileBuffer = Buffer.from(finalBase64, 'base64');
172
- }
173
-
174
- // Verify integrity if content hash is available
175
- if (metadata.contentHash) {
176
- spinner.text = 'Verifying integrity...';
177
- const actualHash = generateContentHash(fileBuffer);
178
-
179
- if (actualHash !== metadata.contentHash) {
180
- throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
181
- }
182
- spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
183
- }
184
-
185
- // Write to file
186
- spinner.text = 'Writing output file...';
187
-
188
- // Ensure output directory exists
189
- const outputDir = path.dirname(outputPath);
190
- if (!fs.existsSync(outputDir)) {
191
- fs.mkdirSync(outputDir, { recursive: true });
192
- }
193
-
194
- fs.writeFileSync(outputPath, fileBuffer);
195
-
196
- spinner.succeed && spinner.succeed('Decoding complete!');
197
-
198
- if (!quiet) {
199
- console.log();
200
- console.log(chalk.green.bold('✓ File decoded successfully!'));
201
- console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
202
- console.log(chalk.cyan(` Output: ${outputPath}`));
203
- console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
204
- if (isMultiPart(metadata)) {
205
- console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
206
- }
207
- }
208
- } catch (error) {
209
- spinner.fail && spinner.fail('Decoding failed');
210
- console.error(chalk.red(`Error: ${error.message}`));
211
- process.exit(1);
212
- }
213
- }
214
-
215
- module.exports = decodeCommand;
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { finished } = require('stream/promises');
4
+ const chalk = require('chalk');
5
+ const ora = require('ora');
6
+ const { readDocxBase64, } = require('../lib/docx-handler');
7
+ const { readXlsxBase64, readXlsxV5 } = require('../lib/xlsx-handler');
8
+ const { validateMetadata, isMultiPart, isStreamingFormat, isLogEmbedFormat, parseMetadata } = require('../lib/metadata');
9
+ const { detectFormat, formatBytes, generateContentHash } = require('../lib/utils');
10
+ const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
11
+ const { decompress, createDecompressStream, decompressBrotli, createBrotliDecompressStream } = require('../lib/compression');
12
+ const { promptPassword, promptOverwrite } = require('../lib/interactive');
13
+ const { extractContent, findMultiPartFiles, mergeBase64Chunks } = require('../lib/file-utils');
14
+ const { HashPassthrough } = require('../lib/streams');
15
+
16
+ /**
17
+ * Read file based on format
18
+ */
19
+ async function readFile(filePath, format) {
20
+ if (format === 'xlsx') {
21
+ return await readXlsxBase64(filePath);
22
+ } else {
23
+ return await readDocxBase64(filePath);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Decode a DOCX/XLSX file back to original format
29
+ */
30
+ async function decodeCommand(inputFile, options) {
31
+ const quiet = options.quiet || false;
32
+ const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, warn: () => {}, text: '' } : ora('Starting decoding process...').start();
33
+
34
+ try {
35
+ const format = detectFormat(inputFile);
36
+ if (!format) {
37
+ throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
38
+ }
39
+
40
+ spinner.text = `Reading ${format.toUpperCase()} file...`;
41
+
42
+ // Read the first file
43
+ const readResult = await readFile(inputFile, format);
44
+
45
+ // Route based on format version
46
+ if (readResult.formatVersion === 'v5') {
47
+ await decodeV5(inputFile, format, readResult, options, spinner, quiet);
48
+ } else {
49
+ // Legacy v3/v4 path
50
+ const { encryptedContent, encryptionMeta, metadata } = extractContent(readResult, format);
51
+ validateMetadata(metadata);
52
+
53
+ const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
54
+ const isCompressed = metadata.compressed || false;
55
+
56
+ spinner.succeed && spinner.succeed(`${format.toUpperCase()} file read successfully`);
57
+
58
+ if (!quiet) {
59
+ console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
60
+ console.log(chalk.cyan(` Original size: ${formatBytes(metadata.originalSize)}`));
61
+ console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
62
+ console.log(chalk.cyan(` Compressed: ${isCompressed ? 'Yes' : 'No'}`));
63
+ }
64
+
65
+ if (isEncrypted && !options.password) {
66
+ if (quiet || options.yes) {
67
+ throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
68
+ }
69
+ options.password = await promptPassword();
70
+ }
71
+
72
+ let outputPath = resolveOutputPath(options, metadata);
73
+
74
+ if (fs.existsSync(outputPath) && !options.force) {
75
+ if (quiet || options.yes) {
76
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
77
+ }
78
+ const shouldOverwrite = await promptOverwrite(outputPath);
79
+ if (!shouldOverwrite) {
80
+ console.log(chalk.yellow('Operation cancelled.'));
81
+ process.exit(0);
82
+ }
83
+ }
84
+
85
+ if (isStreamingFormat(metadata) && format === 'xlsx') {
86
+ await decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
87
+ } else {
88
+ await decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
89
+ }
90
+ }
91
+ } catch (error) {
92
+ spinner.fail && spinner.fail('Decoding failed');
93
+ console.error(chalk.red(`Error: ${error.message}`));
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Resolve output path from options and metadata
100
+ */
101
+ function resolveOutputPath(options, metadata) {
102
+ if (options.output) {
103
+ if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
104
+ return path.join(options.output, metadata.originalFilename);
105
+ } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
106
+ fs.mkdirSync(options.output, { recursive: true });
107
+ return path.join(options.output, metadata.originalFilename);
108
+ } else {
109
+ return options.output;
110
+ }
111
+ }
112
+ return path.join(process.cwd(), metadata.originalFilename);
113
+ }
114
+
115
+ // ─── v5 Log-Embed Decode ────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Decode a v5 log-embed XLSX file
119
+ */
120
+ async function decodeV5(inputFile, format, firstReadResult, options, spinner, quiet) {
121
+ const metadata = firstReadResult.metadata;
122
+ validateMetadata(metadata);
123
+
124
+ const isEncrypted = metadata.encrypted || false;
125
+ const isCompressed = metadata.compressed || false;
126
+ const compressionAlgo = metadata.compressionAlgo || 'brotli';
127
+
128
+ spinner.succeed && spinner.succeed(`${format.toUpperCase()} file read (v5 log-embed format)`);
129
+
130
+ if (!quiet) {
131
+ console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
132
+ console.log(chalk.cyan(` Original size: ${formatBytes(metadata.originalSize)}`));
133
+ console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
134
+ console.log(chalk.cyan(` Compressed: ${isCompressed ? `Yes (${compressionAlgo})` : 'No'}`));
135
+ }
136
+
137
+ if (isEncrypted && !options.password) {
138
+ if (quiet || options.yes) {
139
+ throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
140
+ }
141
+ options.password = await promptPassword();
142
+ }
143
+
144
+ let outputPath = resolveOutputPath(options, metadata);
145
+
146
+ if (fs.existsSync(outputPath) && !options.force) {
147
+ if (quiet || options.yes) {
148
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
149
+ }
150
+ const shouldOverwrite = await promptOverwrite(outputPath);
151
+ if (!shouldOverwrite) {
152
+ console.log(chalk.yellow('Operation cancelled.'));
153
+ process.exit(0);
154
+ }
155
+ }
156
+
157
+ // Ensure output directory exists
158
+ const outputDir = path.dirname(outputPath);
159
+ if (!fs.existsSync(outputDir)) {
160
+ fs.mkdirSync(outputDir, { recursive: true });
161
+ }
162
+
163
+ // Set up output pipeline: [decompress] → hash → file
164
+ const hashStream = new HashPassthrough();
165
+ const outputStream = fs.createWriteStream(outputPath);
166
+
167
+ let decompressStream = null;
168
+ if (isCompressed) {
169
+ if (compressionAlgo === 'brotli') {
170
+ decompressStream = createBrotliDecompressStream();
171
+ } else {
172
+ decompressStream = createDecompressStream();
173
+ }
174
+ decompressStream.pipe(hashStream).pipe(outputStream);
175
+ } else {
176
+ hashStream.pipe(outputStream);
177
+ }
178
+
179
+ const writeTarget = isCompressed ? decompressStream : hashStream;
180
+
181
+ // Check for multi-part
182
+ const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
183
+ let totalPartsFound = 1;
184
+
185
+ if (hasMultipleParts) {
186
+ const inputDir = path.dirname(inputFile);
187
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
188
+ totalPartsFound = allParts.length;
189
+
190
+ if (metadata.totalParts !== null && totalPartsFound !== metadata.totalParts) {
191
+ throw new Error(
192
+ `Missing parts! Found ${totalPartsFound} of ${metadata.totalParts} parts. ` +
193
+ `Make sure all parts are in the same directory.`
194
+ );
195
+ }
196
+
197
+ spinner.text = `Multi-part file detected (${totalPartsFound} parts)`;
198
+ spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
199
+
200
+ for (let i = 0; i < allParts.length; i++) {
201
+ const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
202
+
203
+ const partResult = await readFile(allParts[i].path, format);
204
+
205
+ let partPayload;
206
+ let partEncMeta;
207
+
208
+ if (partResult.formatVersion === 'v5') {
209
+ partPayload = partResult.payloadBuffer;
210
+ partEncMeta = partResult.encryptionMeta;
211
+ } else {
212
+ // Shouldn't happen for v5, but handle gracefully
213
+ const extracted = extractContent(partResult, format);
214
+ partPayload = Buffer.from(extracted.encryptedContent, 'base64');
215
+ partEncMeta = extracted.encryptionMeta;
216
+ }
217
+
218
+ if (isEncrypted) {
219
+ const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
220
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
221
+ try {
222
+ const decrypted = Buffer.concat([decipher.update(partPayload), decipher.final()]);
223
+ writeTarget.write(decrypted);
224
+ } catch (error) {
225
+ throw new Error('Decryption failed: Invalid password or corrupted data');
226
+ }
227
+ } else {
228
+ writeTarget.write(partPayload);
229
+ }
230
+
231
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
232
+ }
233
+ } else {
234
+ // Single file
235
+ spinner.text = 'Decoding...';
236
+
237
+ const payloadBuffer = firstReadResult.payloadBuffer;
238
+ const encryptionMeta = firstReadResult.encryptionMeta;
239
+
240
+ if (isEncrypted) {
241
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
242
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
243
+ try {
244
+ const decrypted = Buffer.concat([decipher.update(payloadBuffer), decipher.final()]);
245
+ writeTarget.write(decrypted);
246
+ } catch (error) {
247
+ throw new Error('Decryption failed: Invalid password or corrupted data');
248
+ }
249
+ } else {
250
+ writeTarget.write(payloadBuffer);
251
+ }
252
+ }
253
+
254
+ // End pipeline and wait
255
+ writeTarget.end();
256
+ await finished(outputStream);
257
+
258
+ // Verify integrity
259
+ if (metadata.contentHash) {
260
+ spinner.text = 'Verifying integrity...';
261
+ const actualHash = hashStream.digest;
262
+ if (actualHash !== metadata.contentHash) {
263
+ try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
264
+ throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
265
+ }
266
+ spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
267
+ }
268
+
269
+ spinner.succeed && spinner.succeed('Decoding complete!');
270
+
271
+ if (!quiet) {
272
+ const outputSize = fs.statSync(outputPath).size;
273
+ console.log();
274
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
275
+ console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
276
+ console.log(chalk.cyan(` Output: ${outputPath}`));
277
+ console.log(chalk.cyan(` Size: ${formatBytes(outputSize)}`));
278
+ if (hasMultipleParts) {
279
+ console.log(chalk.cyan(` Parts merged: ${totalPartsFound}`));
280
+ }
281
+ }
282
+ }
283
+
284
+ // ─── v4 Streaming Decode ────────────────────────────────────────────────────
285
+
286
+ async function decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
287
+ const outputDir = path.dirname(outputPath);
288
+ if (!fs.existsSync(outputDir)) {
289
+ fs.mkdirSync(outputDir, { recursive: true });
290
+ }
291
+
292
+ const hashStream = new HashPassthrough();
293
+ const outputStream = fs.createWriteStream(outputPath);
294
+
295
+ let decompressStream = null;
296
+ if (isCompressed) {
297
+ decompressStream = createDecompressStream();
298
+ decompressStream.pipe(hashStream).pipe(outputStream);
299
+ } else {
300
+ hashStream.pipe(outputStream);
301
+ }
302
+
303
+ const writeTarget = isCompressed ? decompressStream : hashStream;
304
+
305
+ const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
306
+ let totalPartsFound = 1;
307
+
308
+ if (hasMultipleParts) {
309
+ const inputDir = path.dirname(inputFile);
310
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
311
+ totalPartsFound = allParts.length;
312
+
313
+ if (metadata.totalParts !== null && totalPartsFound !== metadata.totalParts) {
314
+ throw new Error(
315
+ `Missing parts! Found ${totalPartsFound} of ${metadata.totalParts} parts. ` +
316
+ `Make sure all parts are in the same directory.`
317
+ );
318
+ }
319
+
320
+ spinner.text = `Multi-part file detected (${totalPartsFound} parts)`;
321
+ spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
322
+
323
+ for (let i = 0; i < allParts.length; i++) {
324
+ const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
325
+
326
+ const partResult = await readFile(allParts[i].path, format);
327
+ const { encryptedContent: partContent, encryptionMeta: partEncMeta } = extractContent(partResult, format);
328
+
329
+ const binaryData = Buffer.from(partContent, 'base64');
330
+
331
+ if (isEncrypted) {
332
+ const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
333
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
334
+ try {
335
+ const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
336
+ writeTarget.write(decrypted);
337
+ } catch (error) {
338
+ throw new Error('Decryption failed: Invalid password or corrupted data');
339
+ }
340
+ } else {
341
+ writeTarget.write(binaryData);
342
+ }
343
+
344
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
345
+ }
346
+ } else {
347
+ spinner.text = 'Decoding...';
348
+ const binaryData = Buffer.from(encryptedContent, 'base64');
349
+
350
+ if (isEncrypted) {
351
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
352
+ const decipher = createDecryptStream(options.password, iv, salt, authTag);
353
+ try {
354
+ const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
355
+ writeTarget.write(decrypted);
356
+ } catch (error) {
357
+ throw new Error('Decryption failed: Invalid password or corrupted data');
358
+ }
359
+ } else {
360
+ writeTarget.write(binaryData);
361
+ }
362
+ }
363
+
364
+ writeTarget.end();
365
+ await finished(outputStream);
366
+
367
+ if (metadata.contentHash) {
368
+ spinner.text = 'Verifying integrity...';
369
+ const actualHash = hashStream.digest;
370
+ if (actualHash !== metadata.contentHash) {
371
+ try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
372
+ throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
373
+ }
374
+ spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
375
+ }
376
+
377
+ spinner.succeed && spinner.succeed('Decoding complete!');
378
+
379
+ if (!quiet) {
380
+ const outputSize = fs.statSync(outputPath).size;
381
+ console.log();
382
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
383
+ console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
384
+ console.log(chalk.cyan(` Output: ${outputPath}`));
385
+ console.log(chalk.cyan(` Size: ${formatBytes(outputSize)}`));
386
+ if (hasMultipleParts) {
387
+ console.log(chalk.cyan(` Parts merged: ${totalPartsFound}`));
388
+ }
389
+ }
390
+ }
391
+
392
+ // ─── v3 Legacy Decode ───────────────────────────────────────────────────────
393
+
394
+ async function decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
395
+ let finalBase64;
396
+
397
+ if (isMultiPart(metadata)) {
398
+ spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
399
+
400
+ const inputDir = path.dirname(inputFile);
401
+ const allParts = findMultiPartFiles(inputDir, metadata.hash, format, metadata.totalParts);
402
+
403
+ if (allParts.length !== metadata.totalParts) {
404
+ throw new Error(
405
+ `Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
406
+ `Make sure all parts are in the same directory.`
407
+ );
408
+ }
409
+
410
+ spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
411
+
412
+ const chunks = [];
413
+ for (let i = 0; i < allParts.length; i++) {
414
+ const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
415
+ const partResult = await readFile(allParts[i].path, format);
416
+ const { encryptedContent: partContent } = extractContent(partResult, format);
417
+ chunks.push(partContent);
418
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
419
+ }
420
+
421
+ spinner.text = 'Merging parts...';
422
+ const mergedContent = mergeBase64Chunks(chunks);
423
+ spinner.succeed && spinner.succeed('Parts merged successfully');
424
+
425
+ if (isEncrypted) {
426
+ spinner.text = 'Decrypting content...';
427
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
428
+ finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
429
+ spinner.succeed && spinner.succeed('Content decrypted');
430
+ } else {
431
+ finalBase64 = mergedContent;
432
+ }
433
+ } else {
434
+ if (isEncrypted) {
435
+ spinner.text = 'Decrypting content...';
436
+ const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
437
+ finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
438
+ spinner.succeed && spinner.succeed('Content decrypted');
439
+ } else {
440
+ finalBase64 = encryptedContent;
441
+ }
442
+ }
443
+
444
+ let fileBuffer;
445
+ if (isCompressed) {
446
+ spinner.text = 'Decompressing...';
447
+ const compressedBuffer = Buffer.from(finalBase64, 'base64');
448
+ fileBuffer = await decompress(compressedBuffer);
449
+ spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
450
+ } else {
451
+ fileBuffer = Buffer.from(finalBase64, 'base64');
452
+ }
453
+
454
+ if (metadata.contentHash) {
455
+ spinner.text = 'Verifying integrity...';
456
+ const actualHash = generateContentHash(fileBuffer);
457
+ if (actualHash !== metadata.contentHash) {
458
+ throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
459
+ }
460
+ spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
461
+ }
462
+
463
+ spinner.text = 'Writing output file...';
464
+ const outputDir = path.dirname(outputPath);
465
+ if (!fs.existsSync(outputDir)) {
466
+ fs.mkdirSync(outputDir, { recursive: true });
467
+ }
468
+
469
+ fs.writeFileSync(outputPath, fileBuffer);
470
+
471
+ spinner.succeed && spinner.succeed('Decoding complete!');
472
+
473
+ if (!quiet) {
474
+ console.log();
475
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
476
+ console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
477
+ console.log(chalk.cyan(` Output: ${outputPath}`));
478
+ console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
479
+ if (isMultiPart(metadata)) {
480
+ console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
481
+ }
482
+ }
483
+ }
484
+
485
+ module.exports = decodeCommand;