stegdoc 6.0.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 CHANGED
@@ -27,7 +27,7 @@ the CLI, and as WebAssembly for the single-file browser tool.
27
27
  - **Multi-part splitting** for payloads larger than one document
28
28
  - **Integrity checking** with SHA-256, verified on decode
29
29
  - **ZIP, directory, or single-file input** on decode, discovered automatically
30
- - **Backward compatible** — the decoder reads every earlier format version
30
+ - **v5 and v6 read support** — the decoder reads both log-embed versions
31
31
 
32
32
  ## Install
33
33
 
@@ -75,7 +75,6 @@ stegdoc verify access_log_20260315_1200_A1B2_part1.xlsx -p mypassword
75
75
  | `-p, --password <pass>` | Encryption password | None (unencrypted) |
76
76
  | `--v5` | Emit the v5 format (PBKDF2) | Off (v6) |
77
77
  | `--v6` | Emit the v6 format (the default) | On |
78
- | `--legacy` | Emit the legacy v4 DOCX format | Off |
79
78
  | `--no-limit` | Bypass the DOCX 1 MB limit | Off |
80
79
  | `--force` | Overwrite existing files | Prompt |
81
80
  | `-q, --quiet` | Minimal output for scripting | Off |
@@ -83,7 +82,8 @@ stegdoc verify access_log_20260315_1200_A1B2_part1.xlsx -p mypassword
83
82
 
84
83
  A directory is zipped before encoding, and several inputs are bundled into one
85
84
  zip first, so a decode hands that archive back. `-s max` produces a single part;
86
- `-s "3 parts"` splits into roughly three.
85
+ `-s "3 parts"` splits into roughly three. Bundling a folder or several inputs
86
+ uses the native engine to build the archive.
87
87
 
88
88
  ```bash
89
89
  stegdoc encode document.pdf -p mysecret # access log spreadsheet
@@ -250,12 +250,18 @@ decoders.
250
250
  The same engine also runs as WebAssembly in a single self-contained HTML file,
251
251
  for offline use where installing anything is not an option.
252
252
 
253
+ ```bash
254
+ npx @stegdoc/web # writes ./stegdoc.html
255
+ ```
256
+
257
+ Or build it from source:
258
+
253
259
  ```bash
254
260
  pnpm install
255
261
  pnpm build:web # -> dist/stegdoc.html
256
262
  ```
257
263
 
258
- Open `dist/stegdoc.html` in a browser. Encoding and decoding run in a Web Worker
264
+ Open the file in a browser. Encoding and decoding run in a Web Worker
259
265
  so the page stays responsive; nothing is uploaded and no network is used. The UI
260
266
  offers XLSX or DOCX, v6 or v5, an optional password, an optional chunk size, and
261
267
  Brotli compression. Several files can be selected at once and are bundled into
@@ -284,8 +290,10 @@ The wire format is specified in [`spec/FORMAT.md`](spec/FORMAT.md).
284
290
 
285
291
  - The decoder auto-detects the version, so no flag is needed to read old files.
286
292
  - v6 is the default output. `--v5` emits the older format; both cover XLSX and
287
- DOCX. `--legacy` produces v4 DOCX only; legacy XLSX is unsupported.
288
- - v3/v4 are read-only paths kept for old files.
293
+ DOCX.
294
+ - v3/v4 (base64 in a hidden sheet or paragraph, gzip) are no longer readable.
295
+ The decoder reports that the format is unsupported; re-encode the file with a
296
+ current version of stegdoc.
289
297
  - Log-embed (v5/v6) decoding requires the native engine. Without it, the CLI
290
298
  fails loudly rather than falling back to an incomplete reader.
291
299
  - Encoding and decoding are randomised, so two encodes of the same input are
@@ -306,11 +314,12 @@ Then run the CLI directly with `node src/index.js <command>`.
306
314
  |---------|---------|
307
315
  | `pnpm test` | conformance against the frozen fixtures (builds native first) |
308
316
  | `pnpm test:rust` | the Rust decoder against the same fixtures |
309
- | `pnpm test:interop` | Rust encode -> JavaScript decode |
317
+ | `pnpm test:interop` | Rust binary encode -> CLI (napi) decode |
310
318
  | `pnpm test:safety` | output-path traversal containment |
311
319
  | `pnpm test:contract` | CLI flags, exit codes, filenames |
312
320
  | `pnpm build:wasm` | the wasm binding -> `web/pkg` |
313
321
  | `pnpm build:web` | the single-file browser tool -> `dist/stegdoc.html` |
322
+ | `pnpm build:web-package` | stage `@stegdoc/web` from `dist/` |
314
323
  | `pnpm test:wasm` | wasm round-trip, headless in Node |
315
324
  | `pnpm test:browser` | the built page, round-trip from `file://` |
316
325
  | `cargo test` | Rust unit and property tests |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stegdoc",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Hide files inside Office documents (XLSX/DOCX) with AES-256 encryption and steganography",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "prepare-publish": "node scripts/prepare-publish.js",
17
17
  "build:wasm": "node scripts/build-wasm.js",
18
18
  "build:web": "node scripts/build-web.js",
19
+ "build:web-package": "node scripts/build-web-package.js",
19
20
  "test": "pnpm build:native && node spec/tools/conformance.js",
20
21
  "test:rust": "node spec/tools/conformance.js --bin target/debug/stegdoc",
21
22
  "test:interop": "node spec/tools/interop.js",
@@ -59,23 +60,21 @@
59
60
  ],
60
61
  "packageManager": "pnpm@10.19.0",
61
62
  "dependencies": {
62
- "adm-zip": "^0.5.16",
63
63
  "chalk": "^4.1.2",
64
64
  "commander": "^14.0.2",
65
65
  "docx": "^9.5.1",
66
- "fast-xml-parser": "^5.3.3",
67
- "file-type": "^16.5.4",
68
66
  "inquirer": "^8.2.6",
69
67
  "ora": "^5.4.1"
70
68
  },
71
69
  "devDependencies": {
70
+ "adm-zip": "^0.5.16",
72
71
  "playwright-core": "^1.63.0"
73
72
  },
74
73
  "optionalDependencies": {
75
- "@stegdoc/binding-win32-x64-msvc": "6.0.0",
76
- "@stegdoc/binding-linux-x64-gnu": "6.0.0",
77
- "@stegdoc/binding-linux-arm64-gnu": "6.0.0",
78
- "@stegdoc/binding-darwin-x64": "6.0.0",
79
- "@stegdoc/binding-darwin-arm64": "6.0.0"
74
+ "@stegdoc/binding-win32-x64-msvc": "6.1.0",
75
+ "@stegdoc/binding-linux-x64-gnu": "6.1.0",
76
+ "@stegdoc/binding-linux-arm64-gnu": "6.1.0",
77
+ "@stegdoc/binding-darwin-x64": "6.1.0",
78
+ "@stegdoc/binding-darwin-arm64": "6.1.0"
80
79
  }
81
80
  }
@@ -1,33 +1,17 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
- const { finished } = require('stream/promises');
4
3
  const chalk = require('chalk');
5
4
  const ora = require('ora');
6
- const { readDocxBase64, } = require('../lib/docx-handler');
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
- const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
11
- const { decompress, createDecompressStream } = require('../lib/compression');
5
+ const { detectFormat, formatBytes } = require('../lib/utils');
12
6
  const { promptPassword, promptOverwrite } = require('../lib/interactive');
13
- const { extractContent, findMultiPartFiles, mergeBase64Chunks } = require('../lib/file-utils');
14
- const { HashPassthrough } = require('../lib/streams');
15
7
  const { loadNative } = require('../lib/native');
16
8
 
17
9
  const NATIVE_REQUIRED =
18
10
  'This is a v5 log-embed file. Decoding it requires the native engine, which is not installed. ' +
19
11
  'Reinstall stegdoc, or build it with `pnpm build:native`.';
20
12
 
21
- /**
22
- * Read file based on format
23
- */
24
- async function readFile(filePath, format) {
25
- if (format === 'xlsx') {
26
- return await readXlsxBase64(filePath);
27
- } else {
28
- return await readDocxBase64(filePath);
29
- }
30
- }
13
+ const LEGACY_UNSUPPORTED =
14
+ 'The legacy v3/v4 format is no longer supported. Re-encode the file with a current version of stegdoc.';
31
15
 
32
16
  /**
33
17
  * Decode a DOCX/XLSX file back to original format
@@ -39,73 +23,81 @@ async function decodeCommand(inputFile, options) {
39
23
  try {
40
24
  const format = detectFormat(inputFile);
41
25
 
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
26
  const native = loadNative();
45
- if (native && (await decodeV5Native(native, inputFile, format, options, spinner, quiet))) {
46
- return;
27
+ if (!native) {
28
+ throw new Error(NATIVE_REQUIRED);
47
29
  }
48
30
 
49
- if (!format) {
31
+ let probe;
32
+ try {
33
+ probe = native.probe(inputFile);
34
+ } catch {
35
+ probe = 'unknown';
36
+ }
37
+ if (probe === 'legacy') {
38
+ throw new Error(LEGACY_UNSUPPORTED);
39
+ }
40
+ if (probe !== 'v5' && probe !== 'archive') {
50
41
  throw new Error(
51
42
  'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
52
43
  );
53
44
  }
54
45
 
55
- spinner.text = `Reading ${format.toUpperCase()} file...`;
56
-
57
- // Read the first file
58
- const readResult = await readFile(inputFile, format);
46
+ const label = format ? format.toUpperCase() : 'ZIP';
47
+ spinner.text = `Reading ${label} file...`;
59
48
 
60
- // Route based on format version
61
- if (readResult.formatVersion === 'v5') {
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);
49
+ let result;
50
+ try {
51
+ result = native.decode(inputFile, options.password || undefined, undefined);
52
+ } catch (error) {
53
+ if (!String(error.message).startsWith('STEGDOC_PASSWORD_REQUIRED')) {
54
+ throw error;
55
+ }
56
+ if (quiet || options.yes) {
57
+ throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
58
+ }
59
+ options.password = await promptPassword();
60
+ result = native.decode(inputFile, options.password || undefined, undefined);
66
61
  }
67
62
 
68
- // Legacy v3/v4 path
69
- {
70
- const { encryptedContent, encryptionMeta, metadata } = extractContent(readResult, format);
71
- validateMetadata(metadata);
63
+ const engineVersion = result.formatVersion || 'v5';
64
+ spinner.succeed && spinner.succeed(`${label} file read (${engineVersion} log-embed format)`);
72
65
 
73
- const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
74
- const isCompressed = metadata.compressed || false;
66
+ if (!quiet) {
67
+ console.log(chalk.cyan(` Original file: ${result.originalFilename}`));
68
+ console.log(chalk.cyan(` Original size: ${formatBytes(result.originalSize)}`));
69
+ console.log(chalk.cyan(` Encrypted: ${result.encrypted ? 'Yes' : 'No'}`));
70
+ console.log(chalk.cyan(` Compressed: ${result.compressed ? 'Yes (brotli)' : 'No'}`));
71
+ }
75
72
 
76
- spinner.succeed && spinner.succeed(`${format.toUpperCase()} file read successfully`);
73
+ const outputPath = resolveNativeOutputPath(options, result.safeFilename);
74
+ const outputDir = path.dirname(outputPath);
75
+ if (!fs.existsSync(outputDir)) {
76
+ fs.mkdirSync(outputDir, { recursive: true });
77
+ }
77
78
 
78
- if (!quiet) {
79
- console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
80
- console.log(chalk.cyan(` Original size: ${formatBytes(metadata.originalSize)}`));
81
- console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
82
- console.log(chalk.cyan(` Compressed: ${isCompressed ? 'Yes' : 'No'}`));
79
+ if (fs.existsSync(outputPath) && !options.force) {
80
+ if (quiet || options.yes) {
81
+ throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
83
82
  }
84
-
85
- if (isEncrypted && !options.password) {
86
- if (quiet || options.yes) {
87
- throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
88
- }
89
- options.password = await promptPassword();
83
+ const shouldOverwrite = await promptOverwrite(outputPath);
84
+ if (!shouldOverwrite) {
85
+ console.log(chalk.yellow('Operation cancelled.'));
86
+ process.exit(0);
90
87
  }
88
+ }
91
89
 
92
- let outputPath = resolveOutputPath(options, metadata);
90
+ fs.writeFileSync(outputPath, result.bytes);
91
+ spinner.succeed && spinner.succeed('Decoding complete!');
93
92
 
94
- if (fs.existsSync(outputPath) && !options.force) {
95
- if (quiet || options.yes) {
96
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
97
- }
98
- const shouldOverwrite = await promptOverwrite(outputPath);
99
- if (!shouldOverwrite) {
100
- console.log(chalk.yellow('Operation cancelled.'));
101
- process.exit(0);
102
- }
103
- }
104
-
105
- if (isStreamingFormat(metadata) && format === 'xlsx') {
106
- await decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
107
- } else {
108
- await decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet);
93
+ if (!quiet) {
94
+ console.log();
95
+ console.log(chalk.green.bold('✓ File decoded successfully!'));
96
+ console.log(chalk.cyan(` Original: ${result.originalFilename}`));
97
+ console.log(chalk.cyan(` Output: ${outputPath}`));
98
+ console.log(chalk.cyan(` Size: ${formatBytes(result.bytes.length)}`));
99
+ if (result.partCount > 1) {
100
+ console.log(chalk.cyan(` Parts merged: ${result.partCount}`));
109
101
  }
110
102
  }
111
103
  } catch (error) {
@@ -115,27 +107,6 @@ async function decodeCommand(inputFile, options) {
115
107
  }
116
108
  }
117
109
 
118
- /**
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).
123
- */
124
- function resolveOutputPath(options, metadata) {
125
- const name = safeFilename(metadata.originalFilename);
126
- if (options.output) {
127
- if (fs.existsSync(options.output) && fs.statSync(options.output).isDirectory()) {
128
- return path.join(options.output, name);
129
- } else if (!path.extname(options.output) && !fs.existsSync(options.output)) {
130
- fs.mkdirSync(options.output, { recursive: true });
131
- return path.join(options.output, name);
132
- } else {
133
- return options.output;
134
- }
135
- }
136
- return path.join(process.cwd(), name);
137
- }
138
-
139
110
  /**
140
111
  * Resolve the output path using a filename the native engine already
141
112
  * sanitised. `safeFilename` is a bare name, so joining it cannot escape.
@@ -154,281 +125,4 @@ function resolveNativeOutputPath(options, safeFilename) {
154
125
  return path.join(process.cwd(), safeFilename);
155
126
  }
156
127
 
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;
170
- }
171
- if (version !== 'v5' && version !== 'archive') return false;
172
-
173
- const label = format ? format.toUpperCase() : 'ZIP';
174
- spinner.text = `Reading ${label} file...`;
175
-
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
- }
183
- if (quiet || options.yes) {
184
- throw new Error('Password is required for encrypted files. Use -p or --password to specify.');
185
- }
186
- options.password = await promptPassword();
187
- result = native.decode(inputFile, options.password || undefined, undefined);
188
- }
189
-
190
- const engineVersion = result.formatVersion || 'v5';
191
- spinner.succeed && spinner.succeed(`${label} file read (${engineVersion} log-embed format)`);
192
-
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'}`));
198
- }
199
-
200
- const outputPath = resolveNativeOutputPath(options, result.safeFilename);
201
- const outputDir = path.dirname(outputPath);
202
- if (!fs.existsSync(outputDir)) {
203
- fs.mkdirSync(outputDir, { recursive: true });
204
- }
205
-
206
- if (fs.existsSync(outputPath) && !options.force) {
207
- if (quiet || options.yes) {
208
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
209
- }
210
- const shouldOverwrite = await promptOverwrite(outputPath);
211
- if (!shouldOverwrite) {
212
- console.log(chalk.yellow('Operation cancelled.'));
213
- process.exit(0);
214
- }
215
- }
216
-
217
- fs.writeFileSync(outputPath, result.bytes);
218
- spinner.succeed && spinner.succeed('Decoding complete!');
219
-
220
- if (!quiet) {
221
- console.log();
222
- console.log(chalk.green.bold('✓ File decoded successfully!'));
223
- console.log(chalk.cyan(` Original: ${result.originalFilename}`));
224
- console.log(chalk.cyan(` Output: ${outputPath}`));
225
- console.log(chalk.cyan(` Size: ${formatBytes(result.bytes.length)}`));
226
- if (result.partCount > 1) {
227
- console.log(chalk.cyan(` Parts merged: ${result.partCount}`));
228
- }
229
- }
230
- return true;
231
- }
232
-
233
- // ─── v4 Streaming Decode ────────────────────────────────────────────────────
234
-
235
- async function decodeStreaming(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
236
- const outputDir = path.dirname(outputPath);
237
- if (!fs.existsSync(outputDir)) {
238
- fs.mkdirSync(outputDir, { recursive: true });
239
- }
240
-
241
- const hashStream = new HashPassthrough();
242
- const outputStream = fs.createWriteStream(outputPath);
243
-
244
- let decompressStream = null;
245
- if (isCompressed) {
246
- decompressStream = createDecompressStream();
247
- decompressStream.pipe(hashStream).pipe(outputStream);
248
- } else {
249
- hashStream.pipe(outputStream);
250
- }
251
-
252
- const writeTarget = isCompressed ? decompressStream : hashStream;
253
-
254
- const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
255
- let totalPartsFound = 1;
256
-
257
- if (hasMultipleParts) {
258
- const inputDir = path.dirname(inputFile);
259
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
260
- totalPartsFound = allParts.length;
261
-
262
- if (metadata.totalParts !== null && totalPartsFound !== metadata.totalParts) {
263
- throw new Error(
264
- `Missing parts! Found ${totalPartsFound} of ${metadata.totalParts} parts. ` +
265
- `Make sure all parts are in the same directory.`
266
- );
267
- }
268
-
269
- spinner.text = `Multi-part file detected (${totalPartsFound} parts)`;
270
- spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
271
-
272
- for (let i = 0; i < allParts.length; i++) {
273
- const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
274
-
275
- const partResult = await readFile(allParts[i].path, format);
276
- const { encryptedContent: partContent, encryptionMeta: partEncMeta } = extractContent(partResult, format);
277
-
278
- const binaryData = Buffer.from(partContent, 'base64');
279
-
280
- if (isEncrypted) {
281
- const { iv, salt, authTag } = unpackEncryptionMeta(partEncMeta);
282
- const decipher = createDecryptStream(options.password, iv, salt, authTag);
283
- try {
284
- const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
285
- writeTarget.write(decrypted);
286
- } catch (error) {
287
- throw new Error('Decryption failed: Invalid password or corrupted data');
288
- }
289
- } else {
290
- writeTarget.write(binaryData);
291
- }
292
-
293
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
294
- }
295
- } else {
296
- spinner.text = 'Decoding...';
297
- const binaryData = Buffer.from(encryptedContent, 'base64');
298
-
299
- if (isEncrypted) {
300
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
301
- const decipher = createDecryptStream(options.password, iv, salt, authTag);
302
- try {
303
- const decrypted = Buffer.concat([decipher.update(binaryData), decipher.final()]);
304
- writeTarget.write(decrypted);
305
- } catch (error) {
306
- throw new Error('Decryption failed: Invalid password or corrupted data');
307
- }
308
- } else {
309
- writeTarget.write(binaryData);
310
- }
311
- }
312
-
313
- writeTarget.end();
314
- await finished(outputStream);
315
-
316
- if (metadata.contentHash) {
317
- spinner.text = 'Verifying integrity...';
318
- const actualHash = hashStream.digest;
319
- if (actualHash !== metadata.contentHash) {
320
- try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
321
- throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
322
- }
323
- spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
324
- }
325
-
326
- spinner.succeed && spinner.succeed('Decoding complete!');
327
-
328
- if (!quiet) {
329
- const outputSize = fs.statSync(outputPath).size;
330
- console.log();
331
- console.log(chalk.green.bold('✓ File decoded successfully!'));
332
- console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
333
- console.log(chalk.cyan(` Output: ${outputPath}`));
334
- console.log(chalk.cyan(` Size: ${formatBytes(outputSize)}`));
335
- if (hasMultipleParts) {
336
- console.log(chalk.cyan(` Parts merged: ${totalPartsFound}`));
337
- }
338
- }
339
- }
340
-
341
- // ─── v3 Legacy Decode ───────────────────────────────────────────────────────
342
-
343
- async function decodeLegacy(inputFile, format, metadata, encryptedContent, encryptionMeta, isEncrypted, isCompressed, options, outputPath, spinner, quiet) {
344
- let finalBase64;
345
-
346
- if (isMultiPart(metadata)) {
347
- spinner.start && (spinner.text = `Multi-part file detected (${metadata.totalParts} parts)`);
348
-
349
- const inputDir = path.dirname(inputFile);
350
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format, metadata.totalParts);
351
-
352
- if (allParts.length !== metadata.totalParts) {
353
- throw new Error(
354
- `Missing parts! Found ${allParts.length} of ${metadata.totalParts} parts. ` +
355
- `Make sure all parts are in the same directory.`
356
- );
357
- }
358
-
359
- spinner.succeed && spinner.succeed(`Found all ${metadata.totalParts} parts`);
360
-
361
- const chunks = [];
362
- for (let i = 0; i < allParts.length; i++) {
363
- const partSpinner = quiet ? spinner : ora(`Reading part ${i + 1} of ${metadata.totalParts}...`).start();
364
- const partResult = await readFile(allParts[i].path, format);
365
- const { encryptedContent: partContent } = extractContent(partResult, format);
366
- chunks.push(partContent);
367
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} read`);
368
- }
369
-
370
- spinner.text = 'Merging parts...';
371
- const mergedContent = mergeBase64Chunks(chunks);
372
- spinner.succeed && spinner.succeed('Parts merged successfully');
373
-
374
- if (isEncrypted) {
375
- spinner.text = 'Decrypting content...';
376
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
377
- finalBase64 = decrypt(mergedContent, options.password, iv, salt, authTag);
378
- spinner.succeed && spinner.succeed('Content decrypted');
379
- } else {
380
- finalBase64 = mergedContent;
381
- }
382
- } else {
383
- if (isEncrypted) {
384
- spinner.text = 'Decrypting content...';
385
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
386
- finalBase64 = decrypt(encryptedContent, options.password, iv, salt, authTag);
387
- spinner.succeed && spinner.succeed('Content decrypted');
388
- } else {
389
- finalBase64 = encryptedContent;
390
- }
391
- }
392
-
393
- let fileBuffer;
394
- if (isCompressed) {
395
- spinner.text = 'Decompressing...';
396
- const compressedBuffer = Buffer.from(finalBase64, 'base64');
397
- fileBuffer = await decompress(compressedBuffer);
398
- spinner.succeed && spinner.succeed(`Decompressed: ${formatBytes(compressedBuffer.length)} → ${formatBytes(fileBuffer.length)}`);
399
- } else {
400
- fileBuffer = Buffer.from(finalBase64, 'base64');
401
- }
402
-
403
- if (metadata.contentHash) {
404
- spinner.text = 'Verifying integrity...';
405
- const actualHash = generateContentHash(fileBuffer);
406
- if (actualHash !== metadata.contentHash) {
407
- throw new Error('Integrity check failed! The file may be corrupted or tampered with.');
408
- }
409
- spinner.succeed && spinner.succeed('Integrity verified (SHA-256 match)');
410
- }
411
-
412
- spinner.text = 'Writing output file...';
413
- const outputDir = path.dirname(outputPath);
414
- if (!fs.existsSync(outputDir)) {
415
- fs.mkdirSync(outputDir, { recursive: true });
416
- }
417
-
418
- fs.writeFileSync(outputPath, fileBuffer);
419
-
420
- spinner.succeed && spinner.succeed('Decoding complete!');
421
-
422
- if (!quiet) {
423
- console.log();
424
- console.log(chalk.green.bold('✓ File decoded successfully!'));
425
- console.log(chalk.cyan(` Original: ${metadata.originalFilename}`));
426
- console.log(chalk.cyan(` Output: ${outputPath}`));
427
- console.log(chalk.cyan(` Size: ${formatBytes(fileBuffer.length)}`));
428
- if (isMultiPart(metadata)) {
429
- console.log(chalk.cyan(` Parts merged: ${metadata.totalParts}`));
430
- }
431
- }
432
- }
433
-
434
128
  module.exports = decodeCommand;