stegdoc 5.7.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 -2
- package/src/commands/decode.js +97 -149
- package/src/commands/encode.js +150 -9
- 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-writer.js +10 -0
- package/src/lib/file-handler.js +0 -113
package/src/commands/decode.js
CHANGED
|
@@ -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
|
|
8
|
-
const { validateMetadata, isMultiPart, isStreamingFormat
|
|
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
|
|
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(
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
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,
|
|
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,
|
|
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(),
|
|
136
|
+
return path.join(process.cwd(), name);
|
|
113
137
|
}
|
|
114
138
|
|
|
115
|
-
// ─── v5 Log-Embed Decode ────────────────────────────────────────────────────
|
|
116
|
-
|
|
117
139
|
/**
|
|
118
|
-
*
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
const engineVersion = result.formatVersion || 'v5';
|
|
191
|
+
spinner.succeed && spinner.succeed(`${label} file read (${engineVersion} log-embed format)`);
|
|
145
192
|
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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: ${
|
|
223
|
+
console.log(chalk.cyan(` Original: ${result.originalFilename}`));
|
|
277
224
|
console.log(chalk.cyan(` Output: ${outputPath}`));
|
|
278
|
-
console.log(chalk.cyan(` Size: ${formatBytes(
|
|
279
|
-
if (
|
|
280
|
-
console.log(chalk.cyan(` Parts merged: ${
|
|
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 ────────────────────────────────────────────────────
|
package/src/commands/encode.js
CHANGED
|
@@ -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);
|
|
@@ -149,7 +247,7 @@ async function encodeCommand(inputFile, options) {
|
|
|
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
|
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;
|