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/encode.js
CHANGED
|
@@ -3,44 +3,146 @@ const fs = require('fs');
|
|
|
3
3
|
const { pipeline } = require('stream/promises');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
const ora = require('ora');
|
|
6
|
-
const
|
|
7
|
-
const { createDocxWithBase64, createDocxV5 } = require('../lib/docx-handler');
|
|
6
|
+
const { createDocxV5 } = require('../lib/docx-handler');
|
|
8
7
|
const { createXlsxPartV5 } = require('../lib/xlsx-handler');
|
|
9
8
|
const { createMetadata, serializeMetadata } = require('../lib/metadata');
|
|
10
9
|
const crypto = require('crypto');
|
|
11
10
|
const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
|
|
12
11
|
const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
|
|
13
|
-
const {
|
|
12
|
+
const { createBrotliCompressStream } = require('../lib/compression');
|
|
14
13
|
const { resetTimeWindow } = require('../lib/decoy-generator');
|
|
15
14
|
const { resetTimeState, BYTES_PER_DATA_LINE, calculateDataLineCount } = require('../lib/log-generator');
|
|
16
15
|
const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
|
|
17
|
-
const {
|
|
16
|
+
const { BinaryChunkCollector, ProgressTransform } = require('../lib/streams');
|
|
17
|
+
const { loadNative } = require('../lib/native');
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* Load the native engine, failing loudly when it is unavailable. Bundling has
|
|
21
|
+
* no JavaScript implementation any more: the archive is built by the core.
|
|
22
|
+
*
|
|
23
|
+
* @returns {object} The native binding.
|
|
24
|
+
*/
|
|
25
|
+
function requireNative() {
|
|
26
|
+
const native = loadNative();
|
|
27
|
+
if (!native || typeof native.zip !== 'function') {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Bundling inputs requires the native engine, which is not installed. ' +
|
|
30
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return native;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Recursively collect a directory's files as archive entries under `prefix`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} dirPath Directory to walk.
|
|
40
|
+
* @param {string} prefix Forward-slash entry path prefix.
|
|
41
|
+
* @param {Array<{name: string, bytes: Buffer}>} entries Accumulator.
|
|
42
|
+
*/
|
|
43
|
+
function collectDirectory(dirPath, prefix, entries) {
|
|
44
|
+
for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
45
|
+
const full = path.join(dirPath, dirent.name);
|
|
46
|
+
const name = `${prefix}/${dirent.name}`;
|
|
47
|
+
if (dirent.isDirectory()) {
|
|
48
|
+
collectDirectory(full, name, entries);
|
|
49
|
+
} else {
|
|
50
|
+
entries.push({ name, bytes: fs.readFileSync(full) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Zip a folder into a buffer, keeping the folder's basename as a prefix.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} folderPath Folder to archive.
|
|
59
|
+
* @returns {Buffer} The archive bytes.
|
|
21
60
|
*/
|
|
22
61
|
function zipFolder(folderPath) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
const native = requireNative();
|
|
63
|
+
const entries = [];
|
|
64
|
+
collectDirectory(folderPath, path.basename(folderPath), entries);
|
|
65
|
+
return native.zip(entries);
|
|
26
66
|
}
|
|
27
67
|
|
|
28
68
|
/**
|
|
29
|
-
*
|
|
69
|
+
* Pick an archive entry name that is not taken yet, disambiguating inputs that
|
|
70
|
+
* share a basename by inserting a counter before the extension.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} name Preferred entry name.
|
|
73
|
+
* @param {Set<string>} used Names already placed in the archive.
|
|
74
|
+
* @returns {string} A free entry name, which is added to `used`.
|
|
30
75
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
76
|
+
function uniqueEntryName(name, used) {
|
|
77
|
+
if (!used.has(name)) {
|
|
78
|
+
used.add(name);
|
|
79
|
+
return name;
|
|
80
|
+
}
|
|
81
|
+
const extension = path.extname(name);
|
|
82
|
+
const stem = name.slice(0, name.length - extension.length);
|
|
83
|
+
let counter = 2;
|
|
84
|
+
let candidate = `${stem}-${counter}${extension}`;
|
|
85
|
+
while (used.has(candidate)) {
|
|
86
|
+
counter += 1;
|
|
87
|
+
candidate = `${stem}-${counter}${extension}`;
|
|
88
|
+
}
|
|
89
|
+
used.add(candidate);
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Zip several inputs into one buffer. Files land at the archive root under
|
|
95
|
+
* their basename; a folder keeps its own name as a prefix.
|
|
96
|
+
*
|
|
97
|
+
* @param {string[]} inputs Absolute or relative paths to files and folders.
|
|
98
|
+
* @returns {Buffer} The archive bytes.
|
|
99
|
+
*/
|
|
100
|
+
function zipInputs(inputs) {
|
|
101
|
+
const native = requireNative();
|
|
102
|
+
const entries = [];
|
|
103
|
+
const used = new Set();
|
|
104
|
+
for (const input of inputs) {
|
|
105
|
+
const base = path.basename(input.replace(/[\\/]+$/, ''));
|
|
106
|
+
const entry = uniqueEntryName(base, used);
|
|
107
|
+
if (fs.statSync(input).isDirectory()) {
|
|
108
|
+
collectDirectory(input, entry, entries);
|
|
109
|
+
} else {
|
|
110
|
+
entries.push({ name: entry, bytes: fs.readFileSync(input) });
|
|
111
|
+
}
|
|
43
112
|
}
|
|
113
|
+
return native.zip(entries);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalise a bundle name: no path segments, always a `.zip` extension.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} name Requested name, possibly empty.
|
|
120
|
+
* @returns {string} The filename recorded in metadata for the bundle.
|
|
121
|
+
*/
|
|
122
|
+
function bundleFilename(name) {
|
|
123
|
+
const base = path.basename((name || '').trim());
|
|
124
|
+
if (!base || base === '.' || base === '..') return 'bundle.zip';
|
|
125
|
+
return base.toLowerCase().endsWith('.zip') ? base : `${base}.zip`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extensions whose contents are already compressed, so Brotli only wastes CPU.
|
|
130
|
+
*/
|
|
131
|
+
const COMPRESSED_EXTENSIONS = new Set([
|
|
132
|
+
'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar',
|
|
133
|
+
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
|
134
|
+
'mp3', 'mp4', 'm4a', 'mov', 'avi', 'mkv', 'webm', 'ogg', 'opus', 'flac',
|
|
135
|
+
'pdf', 'apk', 'jar', 'docx', 'xlsx', 'pptx', 'wasm', 'br',
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check whether an extension is already compressed.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} ext Extension, with or without a leading dot.
|
|
142
|
+
* @returns {boolean} True when the format needs no further compression.
|
|
143
|
+
*/
|
|
144
|
+
function isCompressedExtension(ext) {
|
|
145
|
+
return COMPRESSED_EXTENSIONS.has(String(ext || '').toLowerCase().replace(/^\./, ''));
|
|
44
146
|
}
|
|
45
147
|
|
|
46
148
|
/**
|
|
@@ -57,12 +159,24 @@ async function computeFileHash(filePath) {
|
|
|
57
159
|
}
|
|
58
160
|
|
|
59
161
|
/**
|
|
60
|
-
* Encode
|
|
162
|
+
* Encode one or more inputs to XLSX/DOCX format with optional AES encryption
|
|
163
|
+
* and compression. Several inputs, or a single folder, are zipped first and the
|
|
164
|
+
* archive is what gets encoded, so a decode hands back that zip.
|
|
165
|
+
*
|
|
166
|
+
* @param {string|string[]} input One path, or several to bundle together.
|
|
167
|
+
* @param {object} options Commander options for the encode command.
|
|
61
168
|
*/
|
|
62
|
-
async function encodeCommand(
|
|
169
|
+
async function encodeCommand(input, options) {
|
|
170
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
171
|
+
if (inputs.length === 0) {
|
|
172
|
+
throw new Error('No input given.');
|
|
173
|
+
}
|
|
174
|
+
const bundling = inputs.length > 1;
|
|
175
|
+
const bundleName = bundleFilename(options.bundleName);
|
|
176
|
+
|
|
63
177
|
// Check if we should run interactive mode
|
|
64
178
|
if (shouldRunInteractive(options, 'encode')) {
|
|
65
|
-
const filename = path.basename(
|
|
179
|
+
const filename = bundling ? bundleName : path.basename(inputs[0]);
|
|
66
180
|
console.log(chalk.bold(`\nEncoding: ${filename}`));
|
|
67
181
|
|
|
68
182
|
const interactiveOptions = await promptEncodeOptions(filename);
|
|
@@ -71,7 +185,10 @@ async function encodeCommand(inputFile, options) {
|
|
|
71
185
|
}
|
|
72
186
|
|
|
73
187
|
const quiet = options.quiet || false;
|
|
74
|
-
|
|
188
|
+
if (options.v5 && options.v6) {
|
|
189
|
+
throw new Error('--v5 and --v6 cannot be combined.');
|
|
190
|
+
}
|
|
191
|
+
const version = options.v5 ? 'v5' : 'v6';
|
|
75
192
|
const spinner = quiet ? { start: () => {}, succeed: () => {}, fail: () => {}, info: () => {}, text: '' } : ora('Starting encoding process...').start();
|
|
76
193
|
const createdFiles = [];
|
|
77
194
|
|
|
@@ -80,11 +197,14 @@ async function encodeCommand(inputFile, options) {
|
|
|
80
197
|
resetTimeState();
|
|
81
198
|
|
|
82
199
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
200
|
+
for (const candidate of inputs) {
|
|
201
|
+
if (!fs.existsSync(candidate)) {
|
|
202
|
+
throw new Error(`Path not found: ${candidate}`);
|
|
203
|
+
}
|
|
85
204
|
}
|
|
86
205
|
|
|
87
|
-
const
|
|
206
|
+
const inputFile = inputs[0];
|
|
207
|
+
const isDirectory = !bundling && fs.statSync(inputFile).isDirectory();
|
|
88
208
|
const format = (options.format || 'xlsx').toLowerCase();
|
|
89
209
|
if (format !== 'xlsx' && format !== 'docx') {
|
|
90
210
|
throw new Error('Invalid format. Use "xlsx" or "docx".');
|
|
@@ -99,7 +219,23 @@ async function encodeCommand(inputFile, options) {
|
|
|
99
219
|
let fileSize;
|
|
100
220
|
let tempZipPath = null;
|
|
101
221
|
|
|
102
|
-
if (
|
|
222
|
+
if (bundling) {
|
|
223
|
+
// One archive keeps the pipeline single-payload: a decode hands back the
|
|
224
|
+
// zip, so nothing downstream has to know about multiple inputs.
|
|
225
|
+
spinner.text = `Bundling ${inputs.length} inputs...`;
|
|
226
|
+
const zipBuffer = zipInputs(inputs);
|
|
227
|
+
filename = bundleName;
|
|
228
|
+
extension = '.zip';
|
|
229
|
+
fileSize = zipBuffer.length;
|
|
230
|
+
|
|
231
|
+
tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
|
|
232
|
+
fs.writeFileSync(tempZipPath, zipBuffer);
|
|
233
|
+
streamSource = tempZipPath;
|
|
234
|
+
|
|
235
|
+
spinner.succeed && spinner.succeed(
|
|
236
|
+
`Bundled ${inputs.length} inputs into ${filename} (${formatBytes(fileSize)})`
|
|
237
|
+
);
|
|
238
|
+
} else if (isDirectory) {
|
|
103
239
|
spinner.text = 'Zipping folder...';
|
|
104
240
|
const folderName = path.basename(inputFile);
|
|
105
241
|
const zipBuffer = zipFolder(inputFile);
|
|
@@ -122,34 +258,23 @@ async function encodeCommand(inputFile, options) {
|
|
|
122
258
|
|
|
123
259
|
spinner.text = 'Checking file type...';
|
|
124
260
|
let useCompression = true;
|
|
125
|
-
const
|
|
261
|
+
const extensionKey = extension.replace(/^\./, '').toLowerCase();
|
|
126
262
|
|
|
127
|
-
if (
|
|
263
|
+
if (isCompressedExtension(extensionKey)) {
|
|
128
264
|
useCompression = false;
|
|
129
|
-
spinner.info && spinner.info(`Skipping compression (${
|
|
265
|
+
spinner.info && spinner.info(`Skipping compression (${extensionKey} is already compressed)`);
|
|
130
266
|
}
|
|
131
267
|
|
|
132
|
-
// DOCX v5 size limit (not applicable
|
|
268
|
+
// DOCX v5 size limit (not applicable with --no-limit)
|
|
133
269
|
const noLimit = options.noLimit || options.limit === false;
|
|
134
|
-
if (format === 'docx' && !
|
|
270
|
+
if (format === 'docx' && !noLimit && fileSize > 1 * 1024 * 1024) {
|
|
135
271
|
throw new Error(
|
|
136
272
|
`DOCX format is limited to files under 1 MB (yours is ${formatBytes(fileSize)}). ` +
|
|
137
273
|
`Use XLSX format (-f xlsx) for larger files, or --no-limit to bypass.`
|
|
138
274
|
);
|
|
139
275
|
}
|
|
140
276
|
|
|
141
|
-
//
|
|
142
|
-
if (legacy) {
|
|
143
|
-
if (format === 'docx') {
|
|
144
|
-
await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
|
|
145
|
-
} else {
|
|
146
|
-
throw new Error('Legacy XLSX format (--legacy) is no longer supported. Use v5 format (default) or legacy DOCX (-f docx --legacy).');
|
|
147
|
-
}
|
|
148
|
-
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// === v5 Log-Embed Pipeline ===
|
|
277
|
+
// === Log-Embed Pipeline ===
|
|
153
278
|
const hash = generateHash();
|
|
154
279
|
const outputDir = options.outputDir || process.cwd();
|
|
155
280
|
|
|
@@ -175,6 +300,49 @@ async function encodeCommand(inputFile, options) {
|
|
|
175
300
|
|
|
176
301
|
// Pre-compute content hash
|
|
177
302
|
spinner.text = 'Computing file hash...';
|
|
303
|
+
|
|
304
|
+
// Prefer the native engine; the JavaScript encoder only covers v5.
|
|
305
|
+
const native = loadNative();
|
|
306
|
+
if (version === 'v6' && !native) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
'v6 output requires the native engine (run `pnpm build:native` or install the platform package). Use --v5 for the JavaScript encoder.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (native) {
|
|
312
|
+
spinner.text = 'Encoding...';
|
|
313
|
+
const result = native.encode(streamSource, outputDir, {
|
|
314
|
+
format,
|
|
315
|
+
version,
|
|
316
|
+
password: options.password || undefined,
|
|
317
|
+
chunkSize: chunkSizeBytes === Infinity ? undefined : chunkSizeBytes,
|
|
318
|
+
compress: useCompression,
|
|
319
|
+
force: !!options.force,
|
|
320
|
+
originalFilename: filename,
|
|
321
|
+
originalExtension: extension,
|
|
322
|
+
});
|
|
323
|
+
createdFiles.push(...result.files);
|
|
324
|
+
spinner.succeed && spinner.succeed(`Encoded ${result.partCount} part${result.partCount !== 1 ? 's' : ''}`);
|
|
325
|
+
|
|
326
|
+
if (!quiet) {
|
|
327
|
+
console.log();
|
|
328
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
329
|
+
console.log(chalk.cyan(` Format: ${format.toUpperCase()} (${version} log-embed)`));
|
|
330
|
+
console.log(chalk.cyan(` Hash: ${result.hash}`));
|
|
331
|
+
if (result.partCount > 1) {
|
|
332
|
+
console.log(chalk.cyan(` Parts: ${result.partCount}`));
|
|
333
|
+
}
|
|
334
|
+
console.log(chalk.cyan(` Encrypted: ${result.encrypted ? 'Yes' : 'No'}`));
|
|
335
|
+
console.log(chalk.cyan(` Compressed: ${result.compressed ? 'Yes (Brotli)' : 'No'}`));
|
|
336
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
337
|
+
if (result.encrypted) {
|
|
338
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
178
346
|
const contentHash = await computeFileHash(streamSource);
|
|
179
347
|
|
|
180
348
|
// Generate session salt for encryption
|
|
@@ -252,7 +420,6 @@ async function encodeCommand(inputFile, options) {
|
|
|
252
420
|
encrypted: useEncryption,
|
|
253
421
|
compressed: useCompression,
|
|
254
422
|
contentHash,
|
|
255
|
-
stegoMethod: 'log-embed',
|
|
256
423
|
compressionAlgo: 'brotli',
|
|
257
424
|
payloadSize: payloadBuffer.length,
|
|
258
425
|
dataLineCount,
|
|
@@ -356,96 +523,6 @@ async function encodeCommand(inputFile, options) {
|
|
|
356
523
|
}
|
|
357
524
|
}
|
|
358
525
|
|
|
359
|
-
// ─── Legacy v4 DOCX Pipeline ────────────────────────────────────────────────
|
|
360
|
-
|
|
361
|
-
async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
|
|
362
|
-
const { compress } = require('../lib/compression');
|
|
363
|
-
const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
|
|
364
|
-
const { generateContentHash } = require('../lib/utils');
|
|
365
|
-
|
|
366
|
-
const fileBuffer = fs.readFileSync(inputPath);
|
|
367
|
-
const contentHash = generateContentHash(fileBuffer);
|
|
368
|
-
|
|
369
|
-
let processedBuffer = fileBuffer;
|
|
370
|
-
if (useCompression) {
|
|
371
|
-
spinner.text = 'Compressing...';
|
|
372
|
-
const compressedBuffer = await compress(fileBuffer);
|
|
373
|
-
if (compressedBuffer.length < fileBuffer.length) {
|
|
374
|
-
processedBuffer = compressedBuffer;
|
|
375
|
-
spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
|
|
376
|
-
} else {
|
|
377
|
-
useCompression = false;
|
|
378
|
-
spinner.info && spinner.info('Compression skipped (no size benefit)');
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const base64 = processedBuffer.toString('base64');
|
|
383
|
-
|
|
384
|
-
let contentToStore;
|
|
385
|
-
let encryptionMeta = null;
|
|
386
|
-
|
|
387
|
-
if (useEncryption) {
|
|
388
|
-
spinner.text = 'Encrypting content...';
|
|
389
|
-
const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
|
|
390
|
-
encryptionMeta = packMeta({ iv, salt, authTag });
|
|
391
|
-
contentToStore = ciphertext;
|
|
392
|
-
spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
|
|
393
|
-
} else {
|
|
394
|
-
contentToStore = base64;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const hash = generateHash();
|
|
398
|
-
const outputDir = options.outputDir || process.cwd();
|
|
399
|
-
const format = 'docx';
|
|
400
|
-
|
|
401
|
-
const metadata = createMetadata({
|
|
402
|
-
originalFilename: filename,
|
|
403
|
-
originalExtension: extension,
|
|
404
|
-
hash,
|
|
405
|
-
partNumber: null,
|
|
406
|
-
totalParts: null,
|
|
407
|
-
originalSize: fileSize,
|
|
408
|
-
format,
|
|
409
|
-
encrypted: useEncryption,
|
|
410
|
-
compressed: useCompression,
|
|
411
|
-
contentHash,
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
const outputFilename = generateFilename(hash, null, null, format);
|
|
415
|
-
const outputPath = path.join(outputDir, outputFilename);
|
|
416
|
-
|
|
417
|
-
if (fs.existsSync(outputPath) && !options.force) {
|
|
418
|
-
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const docxContent = useEncryption
|
|
422
|
-
? `${encryptionMeta}|||${contentToStore}`
|
|
423
|
-
: contentToStore;
|
|
424
|
-
|
|
425
|
-
await createDocxWithBase64({
|
|
426
|
-
base64Content: docxContent,
|
|
427
|
-
metadata,
|
|
428
|
-
outputPath,
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
createdFiles.push(outputPath);
|
|
432
|
-
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
433
|
-
|
|
434
|
-
if (!quiet) {
|
|
435
|
-
console.log();
|
|
436
|
-
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
437
|
-
console.log(chalk.cyan(` Format: DOCX (v4 legacy)`));
|
|
438
|
-
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
439
|
-
console.log(chalk.cyan(` Output: ${outputFilename}`));
|
|
440
|
-
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
441
|
-
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
442
|
-
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
443
|
-
if (useEncryption) {
|
|
444
|
-
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
526
|
function cleanupTemp(tempPath) {
|
|
450
527
|
try {
|
|
451
528
|
if (fs.existsSync(tempPath)) {
|
package/src/commands/info.js
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
1
|
const chalk = require('chalk');
|
|
3
2
|
const ora = require('ora');
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
3
|
+
const { formatBytes } = require('../lib/utils');
|
|
4
|
+
const { loadNative } = require('../lib/native');
|
|
5
|
+
|
|
6
|
+
const NATIVE_REQUIRED =
|
|
7
|
+
'Reading file metadata requires the native engine, which is not installed. ' +
|
|
8
|
+
'Reinstall stegdoc, or build it with `pnpm build:native`.';
|
|
9
|
+
|
|
10
|
+
const LEGACY_UNSUPPORTED =
|
|
11
|
+
'The legacy v3/v4 format is no longer supported. Re-encode the file with a current version of stegdoc.';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Print metadata read by the native engine, which handles v5 and v6.
|
|
15
|
+
*/
|
|
16
|
+
function printNativeInfo(info) {
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(chalk.bold.white('File Information:'));
|
|
19
|
+
console.log(chalk.cyan(` Format: ${info.format.toUpperCase()} (${info.formatVersion})`));
|
|
20
|
+
console.log(chalk.cyan(` Stego method: Log-embed (${info.formatVersion})`));
|
|
21
|
+
console.log(chalk.cyan(` Authenticated metadata: ${info.authenticated ? 'Yes' : 'No'}`));
|
|
22
|
+
console.log();
|
|
23
|
+
console.log(chalk.bold.white('Original File:'));
|
|
24
|
+
console.log(chalk.cyan(` Filename: ${info.originalFilename}`));
|
|
25
|
+
console.log(chalk.cyan(` Size: ${formatBytes(info.originalSize)}`));
|
|
26
|
+
console.log();
|
|
27
|
+
console.log(chalk.bold.white('Encoding Options:'));
|
|
28
|
+
console.log(chalk.cyan(` Encrypted: ${info.encrypted ? chalk.yellow('Yes') : 'No'}`));
|
|
29
|
+
console.log(chalk.cyan(` Compressed: ${info.compressed ? chalk.green('Yes') : 'No'}`));
|
|
30
|
+
console.log(chalk.cyan(` Encoded on: ${info.encodingDate || 'Unknown'}`));
|
|
31
|
+
if (info.contentHash) {
|
|
32
|
+
console.log(chalk.cyan(` Content hash: ${info.contentHash.slice(0, 16)}...`));
|
|
33
|
+
}
|
|
34
|
+
console.log();
|
|
35
|
+
if (info.partCount > 1) {
|
|
36
|
+
console.log(chalk.bold.white('Multi-part File:'));
|
|
37
|
+
console.log(chalk.cyan(` This is part: ${info.partNumber} of ${info.partCount}`));
|
|
38
|
+
console.log(chalk.cyan(` Hash: ${info.hash}`));
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(chalk.green.bold(`All ${info.partCount} parts required to decode`));
|
|
41
|
+
} else {
|
|
42
|
+
console.log(chalk.bold.white('Single File:'));
|
|
43
|
+
console.log(chalk.cyan(` Hash: ${info.hash}`));
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(chalk.green.bold('Ready to decode'));
|
|
46
|
+
}
|
|
47
|
+
if (info.encrypted) {
|
|
48
|
+
console.log(chalk.yellow('\nNote: Password required for decoding'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
9
51
|
|
|
10
52
|
/**
|
|
11
53
|
* Show information about an encoded file without decoding
|
|
@@ -14,100 +56,29 @@ async function infoCommand(inputFile, options) {
|
|
|
14
56
|
const spinner = ora('Reading file metadata...').start();
|
|
15
57
|
|
|
16
58
|
try {
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
19
|
-
throw new Error(
|
|
59
|
+
const native = loadNative();
|
|
60
|
+
if (!native) {
|
|
61
|
+
throw new Error(NATIVE_REQUIRED);
|
|
20
62
|
}
|
|
21
63
|
|
|
22
|
-
let
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
64
|
+
let probe;
|
|
65
|
+
try {
|
|
66
|
+
probe = native.probe(inputFile);
|
|
67
|
+
} catch {
|
|
68
|
+
probe = 'unknown';
|
|
27
69
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const metadata = extracted.metadata;
|
|
31
|
-
const encryptionMeta = extracted.encryptionMeta;
|
|
32
|
-
|
|
33
|
-
validateMetadata(metadata);
|
|
34
|
-
|
|
35
|
-
const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
|
|
36
|
-
const isCompressed = metadata.compressed || false;
|
|
37
|
-
const isV5 = isLogEmbedFormat(metadata);
|
|
38
|
-
|
|
39
|
-
spinner.succeed('File metadata read successfully');
|
|
40
|
-
console.log();
|
|
41
|
-
|
|
42
|
-
console.log(chalk.bold.white('File Information:'));
|
|
43
|
-
console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
|
|
44
|
-
console.log(chalk.cyan(` Tool version: ${metadata.version || '1.x'}`));
|
|
45
|
-
if (isV5) {
|
|
46
|
-
console.log(chalk.cyan(` Stego method: Log-embed (v5)`));
|
|
47
|
-
console.log(chalk.cyan(` Compression: ${metadata.compressionAlgo || 'brotli'}`));
|
|
70
|
+
if (probe === 'legacy') {
|
|
71
|
+
throw new Error(LEGACY_UNSUPPORTED);
|
|
48
72
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
console.log(chalk.cyan(` Extension: ${metadata.originalExtension}`));
|
|
54
|
-
console.log(chalk.cyan(` Size: ${formatBytes(metadata.originalSize)}`));
|
|
55
|
-
console.log();
|
|
56
|
-
|
|
57
|
-
console.log(chalk.bold.white('Encoding Options:'));
|
|
58
|
-
console.log(chalk.cyan(` Encrypted: ${isEncrypted ? chalk.yellow('Yes') : 'No'}`));
|
|
59
|
-
console.log(chalk.cyan(` Compressed: ${isCompressed ? chalk.green('Yes') : 'No'}`));
|
|
60
|
-
console.log(chalk.cyan(` Encoded on: ${metadata.encodingDate || 'Unknown'}`));
|
|
61
|
-
|
|
62
|
-
if (metadata.contentHash) {
|
|
63
|
-
console.log(chalk.cyan(` Content hash: ${metadata.contentHash.slice(0, 16)}...`));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (isV5 && metadata.dataLineCount) {
|
|
67
|
-
console.log(chalk.cyan(` Data lines: ${metadata.dataLineCount}`));
|
|
68
|
-
}
|
|
69
|
-
console.log();
|
|
70
|
-
|
|
71
|
-
const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
|
|
72
|
-
if (hasMultipleParts) {
|
|
73
|
-
console.log(chalk.bold.white('Multi-part File:'));
|
|
74
|
-
const inputDir = path.dirname(inputFile);
|
|
75
|
-
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
76
|
-
const totalParts = metadata.totalParts || allParts.length;
|
|
77
|
-
|
|
78
|
-
console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${totalParts}`));
|
|
79
|
-
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
80
|
-
|
|
81
|
-
console.log();
|
|
82
|
-
console.log(chalk.bold.white('Parts found in directory:'));
|
|
83
|
-
|
|
84
|
-
for (let i = 1; i <= totalParts; i++) {
|
|
85
|
-
const part = allParts.find(p => p.partNumber === i);
|
|
86
|
-
if (part) {
|
|
87
|
-
console.log(chalk.green(` ✓ Part ${i}: ${part.filename}`));
|
|
88
|
-
} else {
|
|
89
|
-
console.log(chalk.red(` ✗ Part ${i}: MISSING`));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (allParts.length >= totalParts) {
|
|
94
|
-
console.log();
|
|
95
|
-
console.log(chalk.green.bold('All parts found - ready to decode'));
|
|
96
|
-
} else {
|
|
97
|
-
console.log();
|
|
98
|
-
console.log(chalk.yellow.bold(`Missing ${totalParts - allParts.length} part(s)`));
|
|
99
|
-
}
|
|
100
|
-
} else {
|
|
101
|
-
console.log(chalk.bold.white('Single File:'));
|
|
102
|
-
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
103
|
-
console.log();
|
|
104
|
-
console.log(chalk.green.bold('Ready to decode'));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (isEncrypted) {
|
|
108
|
-
console.log(chalk.yellow('\nNote: Password required for decoding'));
|
|
73
|
+
if (probe !== 'v5' && probe !== 'archive') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
|
|
76
|
+
);
|
|
109
77
|
}
|
|
110
78
|
|
|
79
|
+
const info = native.info(inputFile);
|
|
80
|
+
spinner.succeed('File metadata read successfully');
|
|
81
|
+
printNativeInfo(info);
|
|
111
82
|
} catch (error) {
|
|
112
83
|
spinner.fail('Failed to read file info');
|
|
113
84
|
console.error(chalk.red(`Error: ${error.message}`));
|