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.
@@ -5,7 +5,7 @@ const chalk = require('chalk');
5
5
  const ora = require('ora');
6
6
  const AdmZip = require('adm-zip');
7
7
  const { createDocxWithBase64, createDocxV5 } = require('../lib/docx-handler');
8
- const { createXlsxPartStreaming, createXlsxPartV5 } = require('../lib/xlsx-handler');
8
+ const { createXlsxPartV5 } = require('../lib/xlsx-handler');
9
9
  const { createMetadata, serializeMetadata } = require('../lib/metadata');
10
10
  const crypto = require('crypto');
11
11
  const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
@@ -14,7 +14,8 @@ const { isCompressedMime, createCompressStream, createBrotliCompressStream } = r
14
14
  const { resetTimeWindow } = require('../lib/decoy-generator');
15
15
  const { resetTimeState, BYTES_PER_DATA_LINE, calculateDataLineCount } = require('../lib/log-generator');
16
16
  const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
17
- const { Base64EncodeTransform, ChunkCollector, BinaryChunkCollector, ProgressTransform } = require('../lib/streams');
17
+ const { BinaryChunkCollector, ProgressTransform } = require('../lib/streams');
18
+ const { loadNative } = require('../lib/native');
18
19
 
19
20
  /**
20
21
  * Zip a folder into a buffer
@@ -25,6 +26,65 @@ function zipFolder(folderPath) {
25
26
  return zip.toBuffer();
26
27
  }
27
28
 
29
+ /**
30
+ * Pick an archive entry name that is not taken yet, disambiguating inputs that
31
+ * share a basename by inserting a counter before the extension.
32
+ *
33
+ * @param {string} name Preferred entry name.
34
+ * @param {Set<string>} used Names already placed in the archive.
35
+ * @returns {string} A free entry name, which is added to `used`.
36
+ */
37
+ function uniqueEntryName(name, used) {
38
+ if (!used.has(name)) {
39
+ used.add(name);
40
+ return name;
41
+ }
42
+ const extension = path.extname(name);
43
+ const stem = name.slice(0, name.length - extension.length);
44
+ let counter = 2;
45
+ let candidate = `${stem}-${counter}${extension}`;
46
+ while (used.has(candidate)) {
47
+ counter += 1;
48
+ candidate = `${stem}-${counter}${extension}`;
49
+ }
50
+ used.add(candidate);
51
+ return candidate;
52
+ }
53
+
54
+ /**
55
+ * Zip several inputs into one buffer. Files land at the archive root under
56
+ * their basename; a folder keeps its own name as a prefix.
57
+ *
58
+ * @param {string[]} inputs Absolute or relative paths to files and folders.
59
+ * @returns {Buffer} The archive bytes.
60
+ */
61
+ function zipInputs(inputs) {
62
+ const zip = new AdmZip();
63
+ const used = new Set();
64
+ for (const input of inputs) {
65
+ const base = path.basename(input.replace(/[\\/]+$/, ''));
66
+ const entry = uniqueEntryName(base, used);
67
+ if (fs.statSync(input).isDirectory()) {
68
+ zip.addLocalFolder(input, entry);
69
+ } else {
70
+ zip.addLocalFile(input, '', entry);
71
+ }
72
+ }
73
+ return zip.toBuffer();
74
+ }
75
+
76
+ /**
77
+ * Normalise a bundle name: no path segments, always a `.zip` extension.
78
+ *
79
+ * @param {string} name Requested name, possibly empty.
80
+ * @returns {string} The filename recorded in metadata for the bundle.
81
+ */
82
+ function bundleFilename(name) {
83
+ const base = path.basename((name || '').trim());
84
+ if (!base || base === '.' || base === '..') return 'bundle.zip';
85
+ return base.toLowerCase().endsWith('.zip') ? base : `${base}.zip`;
86
+ }
87
+
28
88
  /**
29
89
  * Detect file type from the first 4KB
30
90
  */
@@ -57,12 +117,24 @@ async function computeFileHash(filePath) {
57
117
  }
58
118
 
59
119
  /**
60
- * Encode a file to XLSX/DOCX format with optional AES encryption and compression.
120
+ * Encode one or more inputs to XLSX/DOCX format with optional AES encryption
121
+ * and compression. Several inputs, or a single folder, are zipped first and the
122
+ * archive is what gets encoded, so a decode hands back that zip.
123
+ *
124
+ * @param {string|string[]} input One path, or several to bundle together.
125
+ * @param {object} options Commander options for the encode command.
61
126
  */
62
- async function encodeCommand(inputFile, options) {
127
+ async function encodeCommand(input, options) {
128
+ const inputs = Array.isArray(input) ? input : [input];
129
+ if (inputs.length === 0) {
130
+ throw new Error('No input given.');
131
+ }
132
+ const bundling = inputs.length > 1;
133
+ const bundleName = bundleFilename(options.bundleName);
134
+
63
135
  // Check if we should run interactive mode
64
136
  if (shouldRunInteractive(options, 'encode')) {
65
- const filename = path.basename(inputFile);
137
+ const filename = bundling ? bundleName : path.basename(inputs[0]);
66
138
  console.log(chalk.bold(`\nEncoding: ${filename}`));
67
139
 
68
140
  const interactiveOptions = await promptEncodeOptions(filename);
@@ -72,6 +144,13 @@ async function encodeCommand(inputFile, options) {
72
144
 
73
145
  const quiet = options.quiet || false;
74
146
  const legacy = options.legacy || false;
147
+ if (options.v5 && options.v6) {
148
+ throw new Error('--v5 and --v6 cannot be combined.');
149
+ }
150
+ if (legacy && (options.v5 || options.v6)) {
151
+ throw new Error('--legacy cannot be combined with --v5 or --v6.');
152
+ }
153
+ const version = options.v5 ? 'v5' : 'v6';
75
154
  const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, text: '' } : ora('Starting encoding process...').start();
76
155
  const createdFiles = [];
77
156
 
@@ -80,11 +159,14 @@ async function encodeCommand(inputFile, options) {
80
159
  resetTimeState();
81
160
 
82
161
  try {
83
- if (!fs.existsSync(inputFile)) {
84
- throw new Error(`Path not found: ${inputFile}`);
162
+ for (const candidate of inputs) {
163
+ if (!fs.existsSync(candidate)) {
164
+ throw new Error(`Path not found: ${candidate}`);
165
+ }
85
166
  }
86
167
 
87
- const isDirectory = fs.statSync(inputFile).isDirectory();
168
+ const inputFile = inputs[0];
169
+ const isDirectory = !bundling && fs.statSync(inputFile).isDirectory();
88
170
  const format = (options.format || 'xlsx').toLowerCase();
89
171
  if (format !== 'xlsx' && format !== 'docx') {
90
172
  throw new Error('Invalid format. Use "xlsx" or "docx".');
@@ -99,7 +181,23 @@ async function encodeCommand(inputFile, options) {
99
181
  let fileSize;
100
182
  let tempZipPath = null;
101
183
 
102
- if (isDirectory) {
184
+ if (bundling) {
185
+ // One archive keeps the pipeline single-payload: a decode hands back the
186
+ // zip, so nothing downstream has to know about multiple inputs.
187
+ spinner.text = `Bundling ${inputs.length} inputs...`;
188
+ const zipBuffer = zipInputs(inputs);
189
+ filename = bundleName;
190
+ extension = '.zip';
191
+ fileSize = zipBuffer.length;
192
+
193
+ tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
194
+ fs.writeFileSync(tempZipPath, zipBuffer);
195
+ streamSource = tempZipPath;
196
+
197
+ spinner.succeed && spinner.succeed(
198
+ `Bundled ${inputs.length} inputs into ${filename} (${formatBytes(fileSize)})`
199
+ );
200
+ } else if (isDirectory) {
103
201
  spinner.text = 'Zipping folder...';
104
202
  const folderName = path.basename(inputFile);
105
203
  const zipBuffer = zipFolder(inputFile);
@@ -143,13 +241,13 @@ async function encodeCommand(inputFile, options) {
143
241
  if (format === 'docx') {
144
242
  await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
145
243
  } else {
146
- await encodeLegacyXlsx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
244
+ throw new Error('Legacy XLSX format (--legacy) is no longer supported. Use v5 format (default) or legacy DOCX (-f docx --legacy).');
147
245
  }
148
246
  if (tempZipPath) cleanupTemp(tempZipPath);
149
247
  return;
150
248
  }
151
249
 
152
- // === v5 Log-Embed Pipeline ===
250
+ // === Log-Embed Pipeline ===
153
251
  const hash = generateHash();
154
252
  const outputDir = options.outputDir || process.cwd();
155
253
 
@@ -175,6 +273,49 @@ async function encodeCommand(inputFile, options) {
175
273
 
176
274
  // Pre-compute content hash
177
275
  spinner.text = 'Computing file hash...';
276
+
277
+ // Prefer the native engine. Legacy mode returned above.
278
+ const native = loadNative();
279
+ if (version === 'v6' && !native) {
280
+ throw new Error(
281
+ 'v6 output requires the native engine (run `pnpm build:native` or install the platform package). Use --v5 for the JavaScript encoder.'
282
+ );
283
+ }
284
+ if (native) {
285
+ spinner.text = 'Encoding...';
286
+ const result = native.encode(streamSource, outputDir, {
287
+ format,
288
+ version,
289
+ password: options.password || undefined,
290
+ chunkSize: chunkSizeBytes === Infinity ? undefined : chunkSizeBytes,
291
+ compress: useCompression,
292
+ force: !!options.force,
293
+ originalFilename: filename,
294
+ originalExtension: extension,
295
+ });
296
+ createdFiles.push(...result.files);
297
+ spinner.succeed && spinner.succeed(`Encoded ${result.partCount} part${result.partCount !== 1 ? 's' : ''}`);
298
+
299
+ if (!quiet) {
300
+ console.log();
301
+ console.log(chalk.green.bold('✓ File encoded successfully!'));
302
+ console.log(chalk.cyan(` Format: ${format.toUpperCase()} (${version} log-embed)`));
303
+ console.log(chalk.cyan(` Hash: ${result.hash}`));
304
+ if (result.partCount > 1) {
305
+ console.log(chalk.cyan(` Parts: ${result.partCount}`));
306
+ }
307
+ console.log(chalk.cyan(` Encrypted: ${result.encrypted ? 'Yes' : 'No'}`));
308
+ console.log(chalk.cyan(` Compressed: ${result.compressed ? 'Yes (Brotli)' : 'No'}`));
309
+ console.log(chalk.cyan(` Location: ${outputDir}`));
310
+ if (result.encrypted) {
311
+ console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
312
+ }
313
+ }
314
+
315
+ if (tempZipPath) cleanupTemp(tempZipPath);
316
+ return;
317
+ }
318
+
178
319
  const contentHash = await computeFileHash(streamSource);
179
320
 
180
321
  // Generate session salt for encryption
@@ -356,168 +497,6 @@ async function encodeCommand(inputFile, options) {
356
497
  }
357
498
  }
358
499
 
359
- // ─── Legacy v4 XLSX Pipeline ────────────────────────────────────────────────
360
-
361
- async function encodeLegacyXlsx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
362
- const hash = generateHash();
363
- const outputDir = options.outputDir || process.cwd();
364
- const format = 'xlsx';
365
- const chunkInput = (options.chunkSize || '').toString().trim();
366
-
367
- // Parse chunk size
368
- const chunkInputLower = chunkInput.toLowerCase();
369
- let chunkSizeBytes;
370
-
371
- if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
372
- chunkSizeBytes = Infinity;
373
- } else if (/^\d+\s*parts?$/i.test(chunkInput)) {
374
- const numParts = parseInt(chunkInput, 10);
375
- if (numParts < 1) throw new Error('Number of parts must be at least 1');
376
- const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
377
- chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
378
- spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
379
- } else if (chunkInput) {
380
- chunkSizeBytes = parseSizeToBytes(chunkInput);
381
- } else {
382
- chunkSizeBytes = 5 * 1024 * 1024;
383
- }
384
-
385
- spinner.text = 'Computing file hash...';
386
- const contentHash = await computeFileHash(inputPath);
387
-
388
- const sessionSalt = useEncryption ? generateSalt() : null;
389
-
390
- spinner.text = useCompression ? 'Compressing and encoding (legacy v4)...' : 'Encoding (legacy v4)...';
391
-
392
- const partFiles = [];
393
-
394
- if (useEncryption) {
395
- const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
396
-
397
- const onBinaryChunkReady = async (binaryBuffer, index) => {
398
- const partNumber = index + 1;
399
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
400
-
401
- const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
402
- const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
403
- const authTag = getAuthTag();
404
- const base64Chunk = encrypted.toString('base64');
405
-
406
- const metadata = createMetadata({
407
- originalFilename: filename,
408
- originalExtension: extension,
409
- hash,
410
- partNumber,
411
- totalParts: null,
412
- originalSize: fileSize,
413
- format,
414
- encrypted: true,
415
- compressed: useCompression,
416
- contentHash,
417
- });
418
-
419
- const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
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 BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
440
- const streams = [fs.createReadStream(inputPath)];
441
- if (!quiet) {
442
- streams.push(new ProgressTransform(fileSize, (processed, total) => {
443
- const pct = Math.min(100, Math.round((processed / total) * 100));
444
- spinner.text = `Compressing (legacy v4)... ${formatBytes(processed)} / ${formatBytes(total)} (${pct}%)`;
445
- }));
446
- }
447
- if (useCompression) streams.push(createCompressStream());
448
- streams.push(collector);
449
- await pipeline(...streams);
450
- } else {
451
- const onChunkReady = async (base64Chunk, index) => {
452
- const partNumber = index + 1;
453
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
454
-
455
- const metadata = createMetadata({
456
- originalFilename: filename,
457
- originalExtension: extension,
458
- hash,
459
- partNumber,
460
- totalParts: null,
461
- originalSize: fileSize,
462
- format,
463
- encrypted: false,
464
- compressed: useCompression,
465
- contentHash,
466
- });
467
-
468
- const outputFilename = generateFilename(hash, partNumber, null, format);
469
- const outputPath = path.join(outputDir, outputFilename);
470
-
471
- if (fs.existsSync(outputPath) && !options.force) {
472
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
473
- }
474
-
475
- await createXlsxPartStreaming({
476
- base64Content: base64Chunk,
477
- encryptionMeta: '',
478
- metadataJson: serializeMetadata(metadata),
479
- outputPath,
480
- });
481
-
482
- partFiles.push(outputPath);
483
- createdFiles.push(outputPath);
484
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
485
- };
486
-
487
- const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
488
- const streams = [fs.createReadStream(inputPath)];
489
- if (!quiet) {
490
- streams.push(new ProgressTransform(fileSize, (processed, total) => {
491
- const pct = Math.min(100, Math.round((processed / total) * 100));
492
- spinner.text = `Encoding (legacy v4)... ${formatBytes(processed)} / ${formatBytes(total)} (${pct}%)`;
493
- }));
494
- }
495
- if (useCompression) streams.push(createCompressStream());
496
- streams.push(new Base64EncodeTransform());
497
- streams.push(collector);
498
- await pipeline(...streams);
499
- }
500
-
501
- const totalParts = partFiles.length;
502
- spinner.succeed && spinner.succeed('Encoding complete!');
503
-
504
- if (!quiet) {
505
- console.log();
506
- console.log(chalk.green.bold('✓ File encoded successfully!'));
507
- console.log(chalk.cyan(` Format: XLSX (v4 legacy)`));
508
- console.log(chalk.cyan(` Hash: ${hash}`));
509
- if (totalParts > 1) {
510
- console.log(chalk.cyan(` Parts: ${totalParts}`));
511
- }
512
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
513
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes (gzip)' : 'No'}`));
514
- console.log(chalk.cyan(` Location: ${outputDir}`));
515
- if (useEncryption) {
516
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
517
- }
518
- }
519
- }
520
-
521
500
  // ─── Legacy v4 DOCX Pipeline ────────────────────────────────────────────────
522
501
 
523
502
  async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
@@ -6,6 +6,46 @@ const { readXlsxBase64 } = require('../lib/xlsx-handler');
6
6
  const { validateMetadata, isMultiPart, isLogEmbedFormat } = require('../lib/metadata');
7
7
  const { detectFormat, formatBytes } = require('../lib/utils');
8
8
  const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
9
+ const { loadNative } = require('../lib/native');
10
+
11
+ /**
12
+ * Print metadata read by the native engine, which handles v5 and v6.
13
+ */
14
+ function printNativeInfo(info) {
15
+ console.log();
16
+ console.log(chalk.bold.white('File Information:'));
17
+ console.log(chalk.cyan(` Format: ${info.format.toUpperCase()} (${info.formatVersion})`));
18
+ console.log(chalk.cyan(` Stego method: Log-embed (${info.formatVersion})`));
19
+ console.log(chalk.cyan(` Authenticated metadata: ${info.authenticated ? 'Yes' : 'No'}`));
20
+ console.log();
21
+ console.log(chalk.bold.white('Original File:'));
22
+ console.log(chalk.cyan(` Filename: ${info.originalFilename}`));
23
+ console.log(chalk.cyan(` Size: ${formatBytes(info.originalSize)}`));
24
+ console.log();
25
+ console.log(chalk.bold.white('Encoding Options:'));
26
+ console.log(chalk.cyan(` Encrypted: ${info.encrypted ? chalk.yellow('Yes') : 'No'}`));
27
+ console.log(chalk.cyan(` Compressed: ${info.compressed ? chalk.green('Yes') : 'No'}`));
28
+ console.log(chalk.cyan(` Encoded on: ${info.encodingDate || 'Unknown'}`));
29
+ if (info.contentHash) {
30
+ console.log(chalk.cyan(` Content hash: ${info.contentHash.slice(0, 16)}...`));
31
+ }
32
+ console.log();
33
+ if (info.partCount > 1) {
34
+ console.log(chalk.bold.white('Multi-part File:'));
35
+ console.log(chalk.cyan(` This is part: ${info.partNumber} of ${info.partCount}`));
36
+ console.log(chalk.cyan(` Hash: ${info.hash}`));
37
+ console.log();
38
+ console.log(chalk.green.bold(`All ${info.partCount} parts required to decode`));
39
+ } else {
40
+ console.log(chalk.bold.white('Single File:'));
41
+ console.log(chalk.cyan(` Hash: ${info.hash}`));
42
+ console.log();
43
+ console.log(chalk.green.bold('Ready to decode'));
44
+ }
45
+ if (info.encrypted) {
46
+ console.log(chalk.yellow('\nNote: Password required for decoding'));
47
+ }
48
+ }
9
49
 
10
50
  /**
11
51
  * Show information about an encoded file without decoding
@@ -19,6 +59,15 @@ async function infoCommand(inputFile, options) {
19
59
  throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
20
60
  }
21
61
 
62
+ // The native engine reads v5 and v6; the JS reader only knows v5.
63
+ const native = loadNative();
64
+ if (native && native.probe(inputFile) === 'v5') {
65
+ const info = native.info(inputFile);
66
+ spinner.succeed('File metadata read successfully');
67
+ printNativeInfo(info);
68
+ return;
69
+ }
70
+
22
71
  let readResult;
23
72
  if (format === 'xlsx') {
24
73
  readResult = await readXlsxBase64(inputFile);
@@ -7,6 +7,66 @@ const { validateMetadata, isMultiPart, isStreamingFormat, isLogEmbedFormat } = r
7
7
  const { detectFormat, formatBytes } = require('../lib/utils');
8
8
  const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
9
9
  const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
10
+ const { loadNative } = require('../lib/native');
11
+
12
+ /**
13
+ * Verify a v5/v6 file with the native engine, which also reads v6.
14
+ */
15
+ function verifyNative(native, inputFile, options, spinner) {
16
+ const info = native.info(inputFile);
17
+ const issues = [];
18
+
19
+ spinner.succeed('Metadata valid');
20
+
21
+ if (info.encrypted) {
22
+ if (!options.password) {
23
+ issues.push('File is encrypted but no password provided');
24
+ spinner.warn('Encryption check skipped (no password)');
25
+ } else {
26
+ spinner.text = 'Verifying decryption...';
27
+ try {
28
+ native.decode(inputFile, options.password);
29
+ spinner.succeed('Decryption password valid');
30
+ } catch (e) {
31
+ issues.push('Decryption failed - wrong password or corrupted data');
32
+ spinner.fail('Decryption check failed');
33
+ }
34
+ }
35
+ } else {
36
+ // No password needed; a full decode also checks the content hash.
37
+ try {
38
+ native.decode(inputFile, undefined);
39
+ spinner.succeed('Content hash valid');
40
+ } catch (e) {
41
+ issues.push(`Decode failed: ${e.message}`);
42
+ spinner.fail('Decode check failed');
43
+ }
44
+ }
45
+
46
+ console.log();
47
+ if (issues.length === 0) {
48
+ console.log(chalk.green.bold('✓ File verification passed!'));
49
+ console.log(chalk.cyan(` Original file: ${info.originalFilename}`));
50
+ console.log(chalk.cyan(` Size: ${formatBytes(info.originalSize)}`));
51
+ console.log(chalk.cyan(` Encrypted: ${info.encrypted ? 'Yes' : 'No'}`));
52
+ console.log(chalk.cyan(` Compressed: ${info.compressed ? 'Yes' : 'No'}`));
53
+ console.log(chalk.cyan(` Format version: ${info.formatVersion} (log-embed)`));
54
+ if (info.partCount > 1) {
55
+ console.log(chalk.cyan(` Parts: ${info.partCount}`));
56
+ }
57
+ console.log();
58
+ console.log(chalk.green('File is ready to decode.'));
59
+ } else {
60
+ console.log(chalk.yellow.bold('⚠ Verification completed with issues:'));
61
+ console.log();
62
+ for (const issue of issues) {
63
+ console.log(chalk.yellow(` • ${issue}`));
64
+ }
65
+ console.log();
66
+ console.log(chalk.red('File cannot be decoded until issues are resolved.'));
67
+ process.exit(1);
68
+ }
69
+ }
10
70
 
11
71
  /**
12
72
  * Verify that a file can be decoded without actually writing output
@@ -21,6 +81,12 @@ async function verifyCommand(inputFile, options) {
21
81
  throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
22
82
  }
23
83
 
84
+ // The native engine reads v5 and v6; the JS reader only knows v5.
85
+ const native = loadNative();
86
+ if (native && native.probe(inputFile) === 'v5') {
87
+ return verifyNative(native, inputFile, options, spinner);
88
+ }
89
+
24
90
  spinner.text = `Reading ${format.toUpperCase()} file...`;
25
91
 
26
92
  let readResult;
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { program } = require('commander');
4
4
  const chalk = require('chalk');
5
+ const { version } = require('../package.json');
5
6
  const encodeCommand = require('./commands/encode');
6
7
  const decodeCommand = require('./commands/decode');
7
8
  const infoCommand = require('./commands/info');
@@ -11,24 +12,27 @@ const verifyCommand = require('./commands/verify');
11
12
  program
12
13
  .name('stegdoc')
13
14
  .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
- .version('5.6.0');
15
+ .version(version);
15
16
 
16
17
  // Encode command
17
18
  program
18
- .command('encode <file>')
19
- .description('Encode a file into XLSX/DOCX format with compression and optional encryption')
19
+ .command('encode <inputs...>')
20
+ .description('Encode one or more files (or folders) into XLSX/DOCX format with compression and optional encryption')
20
21
  .option('-o, --output-dir <dir>', 'Output directory for files', process.cwd())
22
+ .option('--bundle-name <name>', 'Filename recorded for a multi-input bundle', 'bundle.zip')
21
23
  .option('-s, --chunk-size <size>', 'Maximum size per output file (e.g., "5MB", "25MB")', '5MB')
22
24
  .option('-f, --format <format>', 'Output format: xlsx (default) or docx', 'xlsx')
23
25
  .option('-p, --password <password>', 'Encryption password (optional, but recommended)')
24
26
  .option('--force', 'Overwrite existing files without asking')
25
27
  .option('--legacy', 'Use v4 format (hidden sheet + gzip) for backward compatibility')
28
+ .option('--v5', 'Emit the v5 format (PBKDF2) instead of the v6 default')
29
+ .option('--v6', 'Emit the v6 format (default; Argon2id, authenticated metadata)')
26
30
  .option('--no-limit', 'Bypass DOCX 1 MB size limit (large files will produce huge documents)')
27
31
  .option('-q, --quiet', 'Minimal output (for scripting)')
28
32
  .option('-y, --yes', 'Skip interactive prompts, use defaults')
29
- .action(async (file, options) => {
33
+ .action(async (inputs, options) => {
30
34
  try {
31
- await encodeCommand(file, options);
35
+ await encodeCommand(inputs, options);
32
36
  } catch (error) {
33
37
  console.error(chalk.red(`Error: ${error.message}`));
34
38
  process.exit(1);
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The napi binding target table, shared by the loader and the build scripts.
5
+ *
6
+ * The published package ships no binary. Each target becomes an optional
7
+ * dependency `@stegdoc/binding-<suffix>` carrying one `.node`, selected at
8
+ * require time by `src/lib/native.js`. The shape matches the ecosystem norm
9
+ * (`@swc/core-*`, `@rollup/rollup-*`), which is also what the airgap
10
+ * lockfile's `os`/`cpu`/`libc` filter expects.
11
+ *
12
+ * @typedef {object} BindingTarget
13
+ * @property {string} triple - Rust target triple.
14
+ * @property {string} suffix - npm name suffix, `<os>-<cpu>[-<libc>]`.
15
+ * @property {string} os - npm `os` field value.
16
+ * @property {string} cpu - npm `cpu` field value.
17
+ * @property {string} [libc] - npm `libc` field value.
18
+ * @property {string} lib - Artifact filename Cargo produces.
19
+ */
20
+
21
+ /** Scope the platform packages publish under. Changing it is a one-line rename. */
22
+ const SCOPE = '@stegdoc';
23
+
24
+ /** @type {BindingTarget[]} */
25
+ const TARGETS = [
26
+ {
27
+ triple: 'x86_64-pc-windows-msvc',
28
+ suffix: 'win32-x64-msvc',
29
+ os: 'win32',
30
+ cpu: 'x64',
31
+ lib: 'stegdoc_node.dll',
32
+ },
33
+ {
34
+ triple: 'x86_64-unknown-linux-gnu',
35
+ suffix: 'linux-x64-gnu',
36
+ os: 'linux',
37
+ cpu: 'x64',
38
+ libc: 'glibc',
39
+ lib: 'libstegdoc_node.so',
40
+ },
41
+ {
42
+ triple: 'aarch64-unknown-linux-gnu',
43
+ suffix: 'linux-arm64-gnu',
44
+ os: 'linux',
45
+ cpu: 'arm64',
46
+ libc: 'glibc',
47
+ lib: 'libstegdoc_node.so',
48
+ },
49
+ {
50
+ triple: 'x86_64-apple-darwin',
51
+ suffix: 'darwin-x64',
52
+ os: 'darwin',
53
+ cpu: 'x64',
54
+ lib: 'libstegdoc_node.dylib',
55
+ },
56
+ {
57
+ triple: 'aarch64-apple-darwin',
58
+ suffix: 'darwin-arm64',
59
+ os: 'darwin',
60
+ cpu: 'arm64',
61
+ lib: 'libstegdoc_node.dylib',
62
+ },
63
+ ];
64
+
65
+ /**
66
+ * The npm package name for a target.
67
+ * @param {BindingTarget} target
68
+ * @returns {string}
69
+ */
70
+ function packageName(target) {
71
+ return `${SCOPE}/binding-${target.suffix}`;
72
+ }
73
+
74
+ /**
75
+ * Whether the running Linux is musl-based. No musl package is published, so
76
+ * the loader must not pick the glibc binary for it.
77
+ * @returns {boolean}
78
+ */
79
+ function isMusl() {
80
+ if (process.platform !== 'linux') return false;
81
+ try {
82
+ return !process.report.getReport().header.glibcVersionRuntime;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The target matching the running platform, or null when none is published.
90
+ * @returns {BindingTarget|null}
91
+ */
92
+ function hostTarget() {
93
+ if (isMusl()) return null;
94
+ return (
95
+ TARGETS.find((target) => target.os === process.platform && target.cpu === process.arch) || null
96
+ );
97
+ }
98
+
99
+ module.exports = { SCOPE, TARGETS, packageName, hostTarget };