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.
- package/README.md +231 -106
- package/package.json +25 -3
- package/src/commands/decode.js +97 -149
- package/src/commands/encode.js +152 -173
- package/src/commands/info.js +49 -0
- package/src/commands/verify.js +66 -0
- package/src/index.js +9 -5
- package/src/lib/bindings.js +99 -0
- package/src/lib/log-generator.js +5 -2
- package/src/lib/native.js +68 -0
- package/src/lib/streams.js +0 -107
- package/src/lib/utils.js +20 -0
- package/src/lib/xlsx-handler.js +22 -321
- package/src/lib/xlsx-writer.js +308 -0
- package/src/lib/file-handler.js +0 -113
package/src/commands/encode.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
// ===
|
|
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) {
|
package/src/commands/info.js
CHANGED
|
@@ -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);
|
package/src/commands/verify.js
CHANGED
|
@@ -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(
|
|
15
|
+
.version(version);
|
|
15
16
|
|
|
16
17
|
// Encode command
|
|
17
18
|
program
|
|
18
|
-
.command('encode <
|
|
19
|
-
.description('Encode
|
|
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 (
|
|
33
|
+
.action(async (inputs, options) => {
|
|
30
34
|
try {
|
|
31
|
-
await encodeCommand(
|
|
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 };
|