stegdoc 5.7.0 → 6.1.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.
- package/README.md +240 -106
- package/package.json +27 -5
- package/src/commands/decode.js +78 -436
- package/src/commands/encode.js +214 -137
- package/src/commands/info.js +65 -94
- package/src/commands/verify.js +76 -176
- package/src/index.js +9 -6
- package/src/lib/bindings.js +99 -0
- package/src/lib/compression.js +1 -161
- package/src/lib/crypto.js +0 -107
- package/src/lib/docx-handler.js +2 -278
- package/src/lib/log-generator.js +5 -2
- package/src/lib/metadata.js +7 -88
- package/src/lib/native.js +68 -0
- package/src/lib/streams.js +0 -139
- package/src/lib/utils.js +0 -27
- package/src/lib/xlsx-handler.js +1 -272
- package/src/lib/xlsx-writer.js +10 -0
- package/src/lib/file-handler.js +0 -113
- package/src/lib/file-utils.js +0 -160
- package/src/lib/xml-utils.js +0 -115
package/src/commands/decode.js
CHANGED
|
@@ -1,28 +1,17 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { finished } = require('stream/promises');
|
|
4
3
|
const chalk = require('chalk');
|
|
5
4
|
const ora = require('ora');
|
|
6
|
-
const {
|
|
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');
|
|
5
|
+
const { detectFormat, formatBytes } = require('../lib/utils');
|
|
12
6
|
const { promptPassword, promptOverwrite } = require('../lib/interactive');
|
|
13
|
-
const {
|
|
14
|
-
const { HashPassthrough } = require('../lib/streams');
|
|
7
|
+
const { loadNative } = require('../lib/native');
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} else {
|
|
23
|
-
return await readDocxBase64(filePath);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
9
|
+
const NATIVE_REQUIRED =
|
|
10
|
+
'This is a v5 log-embed file. Decoding it requires the native engine, which is not installed. ' +
|
|
11
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.';
|
|
12
|
+
|
|
13
|
+
const LEGACY_UNSUPPORTED =
|
|
14
|
+
'The legacy v3/v4 format is no longer supported. Re-encode the file with a current version of stegdoc.';
|
|
26
15
|
|
|
27
16
|
/**
|
|
28
17
|
* Decode a DOCX/XLSX file back to original format
|
|
@@ -33,454 +22,107 @@ async function decodeCommand(inputFile, options) {
|
|
|
33
22
|
|
|
34
23
|
try {
|
|
35
24
|
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
25
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
|
|
89
|
-
}
|
|
26
|
+
const native = loadNative();
|
|
27
|
+
if (!native) {
|
|
28
|
+
throw new Error(NATIVE_REQUIRED);
|
|
90
29
|
}
|
|
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
30
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
31
|
+
let probe;
|
|
32
|
+
try {
|
|
33
|
+
probe = native.probe(inputFile);
|
|
34
|
+
} catch {
|
|
35
|
+
probe = 'unknown';
|
|
110
36
|
}
|
|
111
|
-
|
|
112
|
-
|
|
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.');
|
|
37
|
+
if (probe === 'legacy') {
|
|
38
|
+
throw new Error(LEGACY_UNSUPPORTED);
|
|
140
39
|
}
|
|
141
|
-
|
|
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) {
|
|
40
|
+
if (probe !== 'v5' && probe !== 'archive') {
|
|
191
41
|
throw new Error(
|
|
192
|
-
|
|
193
|
-
`Make sure all parts are in the same directory.`
|
|
42
|
+
'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
|
|
194
43
|
);
|
|
195
44
|
}
|
|
196
45
|
|
|
197
|
-
|
|
198
|
-
spinner.
|
|
199
|
-
|
|
200
|
-
for (let i = 0; i < allParts.length; i++) {
|
|
201
|
-
const pct = Math.round(((i + 1) / totalPartsFound) * 100);
|
|
202
|
-
const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1}/${totalPartsFound} (${pct}%)...`).start();
|
|
46
|
+
const label = format ? format.toUpperCase() : 'ZIP';
|
|
47
|
+
spinner.text = `Reading ${label} file...`;
|
|
203
48
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
partPayload = partResult.payloadBuffer;
|
|
211
|
-
partEncMeta = partResult.encryptionMeta;
|
|
212
|
-
} else {
|
|
213
|
-
// Shouldn't happen for v5, but handle gracefully
|
|
214
|
-
const extracted = extractContent(partResult, format);
|
|
215
|
-
partPayload = Buffer.from(extracted.encryptedContent, 'base64');
|
|
216
|
-
partEncMeta = extracted.encryptionMeta;
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
result = native.decode(inputFile, options.password || undefined, undefined);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!String(error.message).startsWith('STEGDOC_PASSWORD_REQUIRED')) {
|
|
54
|
+
throw error;
|
|
217
55
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
|
|
221
|
-
const decipher = createDecryptStream(options.password, iv, salt, authTag);
|
|
222
|
-
try {
|
|
223
|
-
const decrypted = Buffer.concat([decipher.update(partPayload), decipher.final()]);
|
|
224
|
-
writeTarget.write(decrypted);
|
|
225
|
-
} catch (error) {
|
|
226
|
-
throw new Error('Decryption failed: Invalid password or corrupted data');
|
|
227
|
-
}
|
|
228
|
-
} else {
|
|
229
|
-
writeTarget.write(partPayload);
|
|
56
|
+
if (quiet || options.yes) {
|
|
57
|
+
throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
|
|
230
58
|
}
|
|
231
|
-
|
|
232
|
-
|
|
59
|
+
options.password = await promptPassword();
|
|
60
|
+
result = native.decode(inputFile, options.password || undefined, undefined);
|
|
233
61
|
}
|
|
234
|
-
} else {
|
|
235
|
-
// Single file
|
|
236
|
-
spinner.text = 'Decoding...';
|
|
237
62
|
|
|
238
|
-
const
|
|
239
|
-
|
|
63
|
+
const engineVersion = result.formatVersion || 'v5';
|
|
64
|
+
spinner.succeed && spinner.succeed(`${label} file read (${engineVersion} log-embed format)`);
|
|
240
65
|
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
writeTarget.write(decrypted);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
throw new Error('Decryption failed: Invalid password or corrupted data');
|
|
249
|
-
}
|
|
250
|
-
} else {
|
|
251
|
-
writeTarget.write(payloadBuffer);
|
|
66
|
+
if (!quiet) {
|
|
67
|
+
console.log(chalk.cyan(` Original file: ${result.originalFilename}`));
|
|
68
|
+
console.log(chalk.cyan(` Original size: ${formatBytes(result.originalSize)}`));
|
|
69
|
+
console.log(chalk.cyan(` Encrypted: ${result.encrypted ? 'Yes' : 'No'}`));
|
|
70
|
+
console.log(chalk.cyan(` Compressed: ${result.compressed ? 'Yes (brotli)' : 'No'}`));
|
|
252
71
|
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// End pipeline and wait
|
|
256
|
-
writeTarget.end();
|
|
257
|
-
await finished(outputStream);
|
|
258
72
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (actualHash !== metadata.contentHash) {
|
|
264
|
-
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
|
|
265
|
-
throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
|
|
73
|
+
const outputPath = resolveNativeOutputPath(options, result.safeFilename);
|
|
74
|
+
const outputDir = path.dirname(outputPath);
|
|
75
|
+
if (!fs.existsSync(outputDir)) {
|
|
76
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
266
77
|
}
|
|
267
|
-
spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
spinner.succeed && spinner.succeed('Decoding complete!');
|
|
271
|
-
|
|
272
|
-
if (!quiet) {
|
|
273
|
-
const outputSize = fs.statSync(outputPath).size;
|
|
274
|
-
console.log();
|
|
275
|
-
console.log(chalk.green.bold('✓ File decoded successfully!'));
|
|
276
|
-
console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
|
|
277
|
-
console.log(chalk.cyan(` Output: ${outputPath}`));
|
|
278
|
-
console.log(chalk.cyan(` Size: ${formatBytes(outputSize)}`));
|
|
279
|
-
if (hasMultipleParts) {
|
|
280
|
-
console.log(chalk.cyan(` Parts merged: ${totalPartsFound}`));
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ─── v4 Streaming Decode ────────────────────────────────────────────────────
|
|
286
|
-
|
|
287
|
-
async function decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
|
|
288
|
-
const outputDir = path.dirname(outputPath);
|
|
289
|
-
if (!fs.existsSync(outputDir)) {
|
|
290
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
291
|
-
}
|
|
292
78
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
let decompressStream = null;
|
|
297
|
-
if (isCompressed) {
|
|
298
|
-
decompressStream = createDecompressStream();
|
|
299
|
-
decompressStream.pipe(hashStream).pipe(outputStream);
|
|
300
|
-
} else {
|
|
301
|
-
hashStream.pipe(outputStream);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const writeTarget = isCompressed ? decompressStream : hashStream;
|
|
305
|
-
|
|
306
|
-
const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
|
|
307
|
-
let totalPartsFound = 1;
|
|
308
|
-
|
|
309
|
-
if (hasMultipleParts) {
|
|
310
|
-
const inputDir = path.dirname(inputFile);
|
|
311
|
-
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
312
|
-
totalPartsFound = allParts.length;
|
|
313
|
-
|
|
314
|
-
if (metadata.totalParts !== null && totalPartsFound !== metadata.totalParts) {
|
|
315
|
-
throw new Error(
|
|
316
|
-
`Missing parts! Found ${totalPartsFound} of ${metadata.totalParts} parts. ` +
|
|
317
|
-
`Make sure all parts are in the same directory.`
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
spinner.text = `Multi-part file detected (${totalPartsFound} parts)`;
|
|
322
|
-
spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
|
|
323
|
-
|
|
324
|
-
for (let i = 0; i < allParts.length; i++) {
|
|
325
|
-
const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
|
|
326
|
-
|
|
327
|
-
const partResult = await readFile(allParts[i].path, format);
|
|
328
|
-
const { encryptedContent: partContent, encryptionMeta: partEncMeta } = extractContent(partResult, format);
|
|
329
|
-
|
|
330
|
-
const binaryData = Buffer.from(partContent, 'base64');
|
|
331
|
-
|
|
332
|
-
if (isEncrypted) {
|
|
333
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
|
|
334
|
-
const decipher = createDecryptStream(options.password, iv, salt, authTag);
|
|
335
|
-
try {
|
|
336
|
-
const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
|
|
337
|
-
writeTarget.write(decrypted);
|
|
338
|
-
} catch (error) {
|
|
339
|
-
throw new Error('Decryption failed: Invalid password or corrupted data');
|
|
340
|
-
}
|
|
341
|
-
} else {
|
|
342
|
-
writeTarget.write(binaryData);
|
|
79
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
80
|
+
if (quiet || options.yes) {
|
|
81
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
343
82
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
spinner.text = 'Decoding...';
|
|
349
|
-
const binaryData = Buffer.from(encryptedContent, 'base64');
|
|
350
|
-
|
|
351
|
-
if (isEncrypted) {
|
|
352
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
353
|
-
const decipher = createDecryptStream(options.password, iv, salt, authTag);
|
|
354
|
-
try {
|
|
355
|
-
const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
|
|
356
|
-
writeTarget.write(decrypted);
|
|
357
|
-
} catch (error) {
|
|
358
|
-
throw new Error('Decryption failed: Invalid password or corrupted data');
|
|
83
|
+
const shouldOverwrite = await promptOverwrite(outputPath);
|
|
84
|
+
if (!shouldOverwrite) {
|
|
85
|
+
console.log(chalk.yellow('Operation cancelled.'));
|
|
86
|
+
process.exit(0);
|
|
359
87
|
}
|
|
360
|
-
} else {
|
|
361
|
-
writeTarget.write(binaryData);
|
|
362
88
|
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
writeTarget.end();
|
|
366
|
-
await finished(outputStream);
|
|
367
|
-
|
|
368
|
-
if (metadata.contentHash) {
|
|
369
|
-
spinner.text = 'Verifying integrity...';
|
|
370
|
-
const actualHash = hashStream.digest;
|
|
371
|
-
if (actualHash !== metadata.contentHash) {
|
|
372
|
-
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
|
|
373
|
-
throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
|
|
374
|
-
}
|
|
375
|
-
spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
|
|
376
|
-
}
|
|
377
89
|
|
|
378
|
-
|
|
90
|
+
fs.writeFileSync(outputPath, result.bytes);
|
|
91
|
+
spinner.succeed && spinner.succeed('Decoding complete!');
|
|
379
92
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
93
|
+
if (!quiet) {
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.green.bold('✓ File decoded successfully!'));
|
|
96
|
+
console.log(chalk.cyan(` Original: ${result.originalFilename}`));
|
|
97
|
+
console.log(chalk.cyan(` Output: ${outputPath}`));
|
|
98
|
+
console.log(chalk.cyan(` Size: ${formatBytes(result.bytes.length)}`));
|
|
99
|
+
if (result.partCount > 1) {
|
|
100
|
+
console.log(chalk.cyan(` Parts merged: ${result.partCount}`));
|
|
101
|
+
}
|
|
389
102
|
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
spinner.fail && spinner.fail('Decoding failed');
|
|
105
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
106
|
+
process.exit(1);
|
|
390
107
|
}
|
|
391
108
|
}
|
|
392
109
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (allParts.length !== metadata.totalParts) {
|
|
405
|
-
throw new Error(
|
|
406
|
-
`Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
|
|
407
|
-
`Make sure all parts are in the same directory.`
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
|
|
412
|
-
|
|
413
|
-
const chunks = [];
|
|
414
|
-
for (let i = 0; i < allParts.length; i++) {
|
|
415
|
-
const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
|
|
416
|
-
const partResult = await readFile(allParts[i].path, format);
|
|
417
|
-
const { encryptedContent: partContent } = extractContent(partResult, format);
|
|
418
|
-
chunks.push(partContent);
|
|
419
|
-
partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
spinner.text = 'Merging parts...';
|
|
423
|
-
const mergedContent = mergeBase64Chunks(chunks);
|
|
424
|
-
spinner.succeed && spinner.succeed('Parts merged successfully');
|
|
425
|
-
|
|
426
|
-
if (isEncrypted) {
|
|
427
|
-
spinner.text = 'Decrypting content...';
|
|
428
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
429
|
-
finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
|
|
430
|
-
spinner.succeed && spinner.succeed('Content decrypted');
|
|
431
|
-
} else {
|
|
432
|
-
finalBase64 = mergedContent;
|
|
433
|
-
}
|
|
434
|
-
} else {
|
|
435
|
-
if (isEncrypted) {
|
|
436
|
-
spinner.text = 'Decrypting content...';
|
|
437
|
-
const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
|
|
438
|
-
finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
|
|
439
|
-
spinner.succeed && spinner.succeed('Content decrypted');
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the output path using a filename the native engine already
|
|
112
|
+
* sanitised. `safeFilename` is a bare name, so joining it cannot escape.
|
|
113
|
+
*/
|
|
114
|
+
function resolveNativeOutputPath(options, safeFilename) {
|
|
115
|
+
if (options.output) {
|
|
116
|
+
if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
|
|
117
|
+
return path.join(options.output, safeFilename);
|
|
118
|
+
} else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
|
|
119
|
+
fs.mkdirSync(options.output, { recursive: true });
|
|
120
|
+
return path.join(options.output, safeFilename);
|
|
440
121
|
} else {
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
let fileBuffer;
|
|
446
|
-
if (isCompressed) {
|
|
447
|
-
spinner.text = 'Decompressing...';
|
|
448
|
-
const compressedBuffer = Buffer.from(finalBase64, 'base64');
|
|
449
|
-
fileBuffer = await decompress(compressedBuffer);
|
|
450
|
-
spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
|
|
451
|
-
} else {
|
|
452
|
-
fileBuffer = Buffer.from(finalBase64, 'base64');
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (metadata.contentHash) {
|
|
456
|
-
spinner.text = 'Verifying integrity...';
|
|
457
|
-
const actualHash = generateContentHash(fileBuffer);
|
|
458
|
-
if (actualHash !== metadata.contentHash) {
|
|
459
|
-
throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
|
|
460
|
-
}
|
|
461
|
-
spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
spinner.text = 'Writing output file...';
|
|
465
|
-
const outputDir = path.dirname(outputPath);
|
|
466
|
-
if (!fs.existsSync(outputDir)) {
|
|
467
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
fs.writeFileSync(outputPath, fileBuffer);
|
|
471
|
-
|
|
472
|
-
spinner.succeed && spinner.succeed('Decoding complete!');
|
|
473
|
-
|
|
474
|
-
if (!quiet) {
|
|
475
|
-
console.log();
|
|
476
|
-
console.log(chalk.green.bold('✓ File decoded successfully!'));
|
|
477
|
-
console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
|
|
478
|
-
console.log(chalk.cyan(` Output: ${outputPath}`));
|
|
479
|
-
console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
|
|
480
|
-
if (isMultiPart(metadata)) {
|
|
481
|
-
console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
|
|
122
|
+
return options.output;
|
|
482
123
|
}
|
|
483
124
|
}
|
|
125
|
+
return path.join(process.cwd(), safeFilename);
|
|
484
126
|
}
|
|
485
127
|
|
|
486
128
|
module.exports = decodeCommand;
|