stegdoc 5.6.0 → 6.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.
@@ -4,14 +4,19 @@ const { finished } = require('stream/promises');
4
4
  const chalk = require('chalk');
5
5
  const ora = require('ora');
6
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');
7
+ const { readXlsxBase64 } = require('../lib/xlsx-handler');
8
+ const { validateMetadata, isMultiPart, isStreamingFormat } = require('../lib/metadata');
9
+ const { detectFormat, formatBytes, generateContentHash, safeFilename } = require('../lib/utils');
10
10
  const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
11
- const { decompress, createDecompressStream, decompressBrotli, createBrotliDecompressStream } = require('../lib/compression');
11
+ const { decompress, createDecompressStream } = require('../lib/compression');
12
12
  const { promptPassword, promptOverwrite } = require('../lib/interactive');
13
13
  const { extractContent, findMultiPartFiles, mergeBase64Chunks } = require('../lib/file-utils');
14
14
  const { HashPassthrough } = require('../lib/streams');
15
+ const { loadNative } = require('../lib/native');
16
+
17
+ const NATIVE_REQUIRED =
18
+ 'This is a v5 log-embed file. Decoding it requires the native engine, which is not installed. ' +
19
+ 'Reinstall stegdoc, or build it with `pnpm build:native`.';
15
20
 
16
21
  /**
17
22
  * Read file based on format
@@ -33,8 +38,18 @@ async function decodeCommand(inputFile, options) {
33
38
 
34
39
  try {
35
40
  const format = detectFormat(inputFile);
41
+
42
+ // Prefer the native engine for v5/v6 files and transport archives. It
43
+ // returns false for anything the legacy JS paths must handle.
44
+ const native = loadNative();
45
+ if (native && (await decodeV5Native(native, inputFile, format, options, spinner, quiet))) {
46
+ return;
47
+ }
48
+
36
49
  if (!format) {
37
- throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
50
+ throw new Error(
51
+ 'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
52
+ );
38
53
  }
39
54
 
40
55
  spinner.text = `Reading ${format.toUpperCase()} file...`;
@@ -44,9 +59,14 @@ async function decodeCommand(inputFile, options) {
44
59
 
45
60
  // Route based on format version
46
61
  if (readResult.formatVersion === 'v5') {
47
- await decodeV5(inputFile, format, readResult, options, spinner, quiet);
48
- } else {
49
- // Legacy v3/v4 path
62
+ // The CommonJS v5 decoder survives only for the legacy v3/v4 formats; its
63
+ // output path is not sanitised, so log-embed files must go through the
64
+ // native engine. Reaching here means the binding is missing.
65
+ throw new Error(NATIVE_REQUIRED);
66
+ }
67
+
68
+ // Legacy v3/v4 path
69
+ {
50
70
  const { encryptedContent, encryptionMeta, metadata } = extractContent(readResult, format);
51
71
  validateMetadata(metadata);
52
72
 
@@ -96,190 +116,118 @@ async function decodeCommand(inputFile, options) {
96
116
  }
97
117
 
98
118
  /**
99
- * Resolve output path from options and metadata
119
+ * Resolve output path from options and metadata.
120
+ *
121
+ * `metadata.originalFilename` is unauthenticated, so it is reduced to a bare
122
+ * name before it is joined onto any directory (FORMAT.md 11.1).
100
123
  */
101
124
  function resolveOutputPath(options, metadata) {
125
+ const name = safeFilename(metadata.originalFilename);
102
126
  if (options.output) {
103
127
  if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
104
- return path.join(options.output, metadata.originalFilename);
128
+ return path.join(options.output, name);
105
129
  } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
106
130
  fs.mkdirSync(options.output, { recursive: true });
107
- return path.join(options.output, metadata.originalFilename);
131
+ return path.join(options.output, name);
108
132
  } else {
109
133
  return options.output;
110
134
  }
111
135
  }
112
- return path.join(process.cwd(), metadata.originalFilename);
136
+ return path.join(process.cwd(), name);
113
137
  }
114
138
 
115
- // ─── v5 Log-Embed Decode ────────────────────────────────────────────────────
116
-
117
139
  /**
118
- * Decode a v5 log-embed XLSX file
140
+ * Resolve the output path using a filename the native engine already
141
+ * sanitised. `safeFilename` is a bare name, so joining it cannot escape.
119
142
  */
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)`);
143
+ function resolveNativeOutputPath(options, safeFilename) {
144
+ if (options.output) {
145
+ if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
146
+ return path.join(options.output, safeFilename);
147
+ } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
148
+ fs.mkdirSync(options.output, { recursive: true });
149
+ return path.join(options.output, safeFilename);
150
+ } else {
151
+ return options.output;
152
+ }
153
+ }
154
+ return path.join(process.cwd(), safeFilename);
155
+ }
129
156
 
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'}`));
157
+ /**
158
+ * Decode a v5 file through the native engine.
159
+ *
160
+ * Returns true when the file was handled, false when it is not a v5 file the
161
+ * native engine understands and the caller should fall back to the JS paths.
162
+ */
163
+ async function decodeV5Native(native, inputFile, format, options, spinner, quiet) {
164
+ let version;
165
+ try {
166
+ version = native.probe(inputFile);
167
+ } catch {
168
+ // Not a readable container; let the JS paths produce the error.
169
+ return false;
135
170
  }
171
+ if (version !== 'v5' && version !== 'archive') return false;
172
+
173
+ const label = format ? format.toUpperCase() : 'ZIP';
174
+ spinner.text = `Reading ${label} file...`;
136
175
 
137
- if (isEncrypted && !options.password) {
176
+ let result;
177
+ try {
178
+ result = native.decode(inputFile, options.password || undefined, undefined);
179
+ } catch (error) {
180
+ if (!String(error.message).startsWith('STEGDOC_PASSWORD_REQUIRED')) {
181
+ throw error;
182
+ }
138
183
  if (quiet || options.yes) {
139
184
  throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
140
185
  }
141
186
  options.password = await promptPassword();
187
+ result = native.decode(inputFile, options.password || undefined, undefined);
142
188
  }
143
189
 
144
- let outputPath = resolveOutputPath(options, metadata);
190
+ const engineVersion = result.formatVersion || 'v5';
191
+ spinner.succeed && spinner.succeed(`${label} file read (${engineVersion} log-embed format)`);
145
192
 
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
- }
193
+ if (!quiet) {
194
+ console.log(chalk.cyan(` Original file: ${result.originalFilename}`));
195
+ console.log(chalk.cyan(` Original size: ${formatBytes(result.originalSize)}`));
196
+ console.log(chalk.cyan(` Encrypted: ${result.encrypted ? 'Yes' : 'No'}`));
197
+ console.log(chalk.cyan(` Compressed: ${result.compressed ? 'Yes (brotli)' : 'No'}`));
155
198
  }
156
199
 
157
- // Ensure output directory exists
200
+ const outputPath = resolveNativeOutputPath(options, result.safeFilename);
158
201
  const outputDir = path.dirname(outputPath);
159
202
  if (!fs.existsSync(outputDir)) {
160
203
  fs.mkdirSync(outputDir, { recursive: true });
161
204
  }
162
205
 
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 pct = Math.round(((i + 1) / totalPartsFound) * 100);
202
- const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1}/${totalPartsFound} (${pct}%)...`).start();
203
-
204
- const partResult = await readFile(allParts[i].path, format);
205
-
206
- let partPayload;
207
- let partEncMeta;
208
-
209
- if (partResult.formatVersion === 'v5') {
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;
217
- }
218
-
219
- if (isEncrypted) {
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);
230
- }
231
-
232
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1}/${totalPartsFound} decoded`);
233
- }
234
- } else {
235
- // Single file
236
- spinner.text = 'Decoding...';
237
-
238
- const payloadBuffer = firstReadResult.payloadBuffer;
239
- const encryptionMeta = firstReadResult.encryptionMeta;
240
-
241
- if (isEncrypted) {
242
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
243
- const decipher = createDecryptStream(options.password, iv, salt, authTag);
244
- try {
245
- const decrypted = Buffer.concat([decipher.update(payloadBuffer), decipher.final()]);
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);
206
+ if (fs.existsSync(outputPath) && !options.force) {
207
+ if (quiet || options.yes) {
208
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
252
209
  }
253
- }
254
-
255
- // End pipeline and wait
256
- writeTarget.end();
257
- await finished(outputStream);
258
-
259
- // Verify integrity
260
- if (metadata.contentHash) {
261
- spinner.text = 'Verifying integrity...';
262
- const actualHash = hashStream.digest;
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.');
210
+ const shouldOverwrite = await promptOverwrite(outputPath);
211
+ if (!shouldOverwrite) {
212
+ console.log(chalk.yellow('Operation cancelled.'));
213
+ process.exit(0);
266
214
  }
267
- spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
268
215
  }
269
216
 
217
+ fs.writeFileSync(outputPath, result.bytes);
270
218
  spinner.succeed && spinner.succeed('Decoding complete!');
271
219
 
272
220
  if (!quiet) {
273
- const outputSize = fs.statSync(outputPath).size;
274
221
  console.log();
275
222
  console.log(chalk.green.bold('✓ File decoded successfully!'));
276
- console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
223
+ console.log(chalk.cyan(` Original: ${result.originalFilename}`));
277
224
  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}`));
225
+ console.log(chalk.cyan(` Size: ${formatBytes(result.bytes.length)}`));
226
+ if (result.partCount > 1) {
227
+ console.log(chalk.cyan(` Parts merged: ${result.partCount}`));
281
228
  }
282
229
  }
230
+ return true;
283
231
  }
284
232
 
285
233
  // ─── v4 Streaming Decode ────────────────────────────────────────────────────