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.
@@ -1,139 +1,102 @@
1
- const path = require('path');
2
1
  const chalk = require('chalk');
3
2
  const ora = require('ora');
4
- const { readDocxBase64 } = require('../lib/docx-handler');
5
- const { readXlsxBase64 } = require('../lib/xlsx-handler');
6
- const { validateMetadata, isMultiPart, isStreamingFormat, isLogEmbedFormat } = require('../lib/metadata');
7
- const { detectFormat, formatBytes } = require('../lib/utils');
8
- const { decrypt, unpackEncryptionMeta, createDecryptStream } = require('../lib/crypto');
9
- const { extractContent, findMultiPartFiles } = require('../lib/file-utils');
3
+ const { formatBytes } = require('../lib/utils');
4
+ const { loadNative } = require('../lib/native');
5
+
6
+ const NATIVE_REQUIRED =
7
+ 'Verifying a file 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.';
10
12
 
11
13
  /**
12
- * Verify that a file can be decoded without actually writing output
14
+ * Verify a v5/v6 file with the native engine, which also reads v6.
13
15
  */
14
- async function verifyCommand(inputFile, options) {
15
- const spinner = ora('Verifying file...').start();
16
+ function verifyNative(native, inputFile, options, spinner) {
17
+ const info = native.info(inputFile);
16
18
  const issues = [];
17
19
 
18
- try {
19
- const format = detectFormat(inputFile);
20
- if (!format) {
21
- throw new Error('Unknown file format. Supported formats: .xlsx, .docx');
22
- }
20
+ spinner.succeed('Metadata valid');
23
21
 
24
- spinner.text = `Reading ${format.toUpperCase()} file...`;
25
-
26
- let readResult;
27
- if (format === 'xlsx') {
28
- readResult = await readXlsxBase64(inputFile);
22
+ if (info.encrypted) {
23
+ if (!options.password) {
24
+ issues.push('File is encrypted but no password provided');
25
+ spinner.warn('Encryption check skipped (no password)');
29
26
  } else {
30
- readResult = await readDocxBase64(inputFile);
27
+ spinner.text = 'Verifying decryption...';
28
+ try {
29
+ native.decode(inputFile, options.password);
30
+ spinner.succeed('Decryption password valid');
31
+ } catch (e) {
32
+ issues.push('Decryption failed - wrong password or corrupted data');
33
+ spinner.fail('Decryption check failed');
34
+ }
31
35
  }
32
-
33
- const extracted = extractContent(readResult, format);
34
- const metadata = extracted.metadata;
35
- const encryptionMeta = extracted.encryptionMeta;
36
-
36
+ } else {
37
+ // No password needed; a full decode also checks the content hash.
37
38
  try {
38
- validateMetadata(metadata);
39
- spinner.succeed('Metadata valid');
39
+ native.decode(inputFile, undefined);
40
+ spinner.succeed('Content hash valid');
40
41
  } catch (e) {
41
- issues.push(`Metadata: ${e.message}`);
42
- spinner.warn('Metadata issues found');
42
+ issues.push(`Decode failed: ${e.message}`);
43
+ spinner.fail('Decode check failed');
43
44
  }
45
+ }
44
46
 
45
- const isEncrypted = metadata.encrypted || (encryptionMeta && encryptionMeta.length > 0);
46
- const isV5 = isLogEmbedFormat(metadata);
47
- const isV4 = !isV5 && isStreamingFormat(metadata);
48
-
49
- // Check multi-part
50
- const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
51
- if (hasMultipleParts) {
52
- spinner.text = 'Checking multi-part files...';
53
- const inputDir = path.dirname(inputFile);
54
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
55
- const totalParts = metadata.totalParts || allParts.length;
56
-
57
- if (metadata.totalParts !== null && allParts.length !== metadata.totalParts) {
58
- const missing = [];
59
- const foundParts = new Set(allParts.map(p => p.partNumber));
60
- for (let i = 1; i <= metadata.totalParts; i++) {
61
- if (!foundParts.has(i)) missing.push(i);
62
- }
63
- issues.push(`Missing parts: ${missing.join(', ')}`);
64
- spinner.warn(`Found ${allParts.length}/${metadata.totalParts} parts`);
65
- } else {
66
- spinner.succeed(`All ${totalParts} parts found`);
67
- }
47
+ console.log();
48
+ if (issues.length === 0) {
49
+ console.log(chalk.green.bold('✓ File verification passed!'));
50
+ console.log(chalk.cyan(` Original file: ${info.originalFilename}`));
51
+ console.log(chalk.cyan(` Size: ${formatBytes(info.originalSize)}`));
52
+ console.log(chalk.cyan(` Encrypted: ${info.encrypted ? 'Yes' : 'No'}`));
53
+ console.log(chalk.cyan(` Compressed: ${info.compressed ? 'Yes' : 'No'}`));
54
+ console.log(chalk.cyan(` Format version: ${info.formatVersion} (log-embed)`));
55
+ if (info.partCount > 1) {
56
+ console.log(chalk.cyan(` Parts: ${info.partCount}`));
68
57
  }
69
-
70
- // Check password for encrypted files
71
- if (isEncrypted) {
72
- if (!options.password) {
73
- issues.push('File is encrypted but no password provided');
74
- spinner.warn('Encryption check skipped (no password)');
75
- } else {
76
- spinner.text = 'Verifying decryption...';
77
- try {
78
- if (isV5) {
79
- await verifyV5Encryption(extracted, options.password);
80
- } else if (isV4) {
81
- await verifyV4Encryption(inputFile, format, encryptionMeta, options.password);
82
- } else {
83
- await verifyV3Encryption(inputFile, format, metadata, extracted.encryptedContent, encryptionMeta, options.password);
84
- }
85
- spinner.succeed('Decryption password valid');
86
- } catch (e) {
87
- issues.push('Decryption failed - wrong password or corrupted data');
88
- spinner.fail('Decryption check failed');
89
- }
90
- }
58
+ console.log();
59
+ console.log(chalk.green('File is ready to decode.'));
60
+ } else {
61
+ console.log(chalk.yellow.bold('⚠ Verification completed with issues:'));
62
+ console.log();
63
+ for (const issue of issues) {
64
+ console.log(chalk.yellow(` • ${issue}`));
91
65
  }
92
-
93
- // Summary
94
66
  console.log();
67
+ console.log(chalk.red('File cannot be decoded until issues are resolved.'));
68
+ process.exit(1);
69
+ }
70
+ }
95
71
 
96
- if (issues.length === 0) {
97
- console.log(chalk.green.bold('✓ File verification passed!'));
98
- console.log(chalk.cyan(` Original file: ${metadata.originalFilename}`));
99
- console.log(chalk.cyan(` Size: ${formatBytes(metadata.originalSize)}`));
100
- console.log(chalk.cyan(` Encrypted: ${isEncrypted ? 'Yes' : 'No'}`));
101
- console.log(chalk.cyan(` Compressed: ${metadata.compressed ? 'Yes' : 'No'}`));
102
-
103
- let versionStr = 'v3 (legacy)';
104
- if (isV5) versionStr = 'v5 (log-embed)';
105
- else if (isV4) versionStr = 'v4 (streaming)';
106
- console.log(chalk.cyan(` Format version: ${versionStr}`));
107
-
108
- if (hasMultipleParts) {
109
- const totalParts = metadata.totalParts || 'unknown';
110
- console.log(chalk.cyan(` Parts: ${totalParts}`));
111
- }
72
+ /**
73
+ * Verify that a file can be decoded without actually writing output
74
+ */
75
+ async function verifyCommand(inputFile, options) {
76
+ const spinner = ora('Verifying file...').start();
112
77
 
113
- console.log();
114
- console.log(chalk.green('File is ready to decode.'));
115
- } else {
116
- console.log(chalk.yellow.bold('⚠ Verification completed with issues:'));
117
- console.log();
118
- for (const issue of issues) {
119
- console.log(chalk.yellow(` • ${issue}`));
120
- }
121
- console.log();
78
+ try {
79
+ const native = loadNative();
80
+ if (!native) {
81
+ throw new Error(NATIVE_REQUIRED);
82
+ }
122
83
 
123
- const hasBlockingIssue = issues.some(i =>
124
- i.includes('Missing parts') ||
125
- i.includes('Decryption failed') ||
126
- i.includes('Metadata')
84
+ let probe;
85
+ try {
86
+ probe = native.probe(inputFile);
87
+ } catch {
88
+ probe = 'unknown';
89
+ }
90
+ if (probe === 'legacy') {
91
+ throw new Error(LEGACY_UNSUPPORTED);
92
+ }
93
+ if (probe !== 'v5' && probe !== 'archive') {
94
+ throw new Error(
95
+ 'Unknown file format. Supported formats: .xlsx, .docx, or a zip of them'
127
96
  );
128
-
129
- if (hasBlockingIssue) {
130
- console.log(chalk.red('File cannot be decoded until issues are resolved.'));
131
- process.exit(1);
132
- } else {
133
- console.log(chalk.yellow('File may still be decodable. Run decode to attempt.'));
134
- }
135
97
  }
136
98
 
99
+ return verifyNative(native, inputFile, options, spinner);
137
100
  } catch (error) {
138
101
  spinner.fail('Verification failed');
139
102
  console.error(chalk.red(`Error: ${error.message}`));
@@ -141,67 +104,4 @@ async function verifyCommand(inputFile, options) {
141
104
  }
142
105
  }
143
106
 
144
- /**
145
- * Verify v5 log-embed encryption
146
- */
147
- async function verifyV5Encryption(extracted, password) {
148
- const { payloadBuffer, encryptionMeta } = extracted;
149
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
150
- const decipher = createDecryptStream(password, iv, salt, authTag);
151
- decipher.update(payloadBuffer);
152
- decipher.final();
153
- }
154
-
155
- /**
156
- * Verify v4 per-part encryption
157
- */
158
- async function verifyV4Encryption(inputFile, format, encryptionMeta, password) {
159
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
160
-
161
- let readResult;
162
- if (format === 'xlsx') {
163
- readResult = await readXlsxBase64(inputFile);
164
- } else {
165
- readResult = await readDocxBase64(inputFile);
166
- }
167
-
168
- const { encryptedContent } = extractContent(readResult, format);
169
- const binaryData = Buffer.from(encryptedContent, 'base64');
170
-
171
- const decipher = createDecryptStream(password, iv, salt, authTag);
172
- decipher.update(binaryData);
173
- decipher.final();
174
- }
175
-
176
- /**
177
- * Verify v3 shared encryption
178
- */
179
- async function verifyV3Encryption(inputFile, format, metadata, encryptedContent, encryptionMeta, password) {
180
- const { iv, salt, authTag } = unpackEncryptionMeta(encryptionMeta);
181
-
182
- let fullContent = encryptedContent;
183
-
184
- if (isMultiPart(metadata)) {
185
- const inputDir = path.dirname(inputFile);
186
- const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
187
-
188
- if (allParts.length === metadata.totalParts) {
189
- const contentParts = [];
190
- for (const part of allParts) {
191
- let partResult;
192
- if (format === 'xlsx') {
193
- partResult = await readXlsxBase64(part.path);
194
- } else {
195
- partResult = await readDocxBase64(part.path);
196
- }
197
- const { encryptedContent: partContent } = extractContent(partResult, format);
198
- contentParts.push(partContent);
199
- }
200
- fullContent = contentParts.join('');
201
- }
202
- }
203
-
204
- decrypt(fullContent, password, iv, salt, authTag);
205
- }
206
-
207
107
  module.exports = verifyCommand;
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,26 @@ const verifyCommand = require('./commands/verify');
11
12
  program
12
13
  .name('stegdoc')
13
14
  .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
- .version('5.6.0');
15
+ .version(version);
15
16
 
16
17
  // Encode command
17
18
  program
18
- .command('encode <file>')
19
- .description('Encode a file into XLSX/DOCX format with compression and optional encryption')
19
+ .command('encode <inputs...>')
20
+ .description('Encode one or more files (or folders) into XLSX/DOCX format with compression and optional encryption')
20
21
  .option('-o, --output-dir <dir>', 'Output directory for files', process.cwd())
22
+ .option('--bundle-name <name>', 'Filename recorded for a multi-input bundle', 'bundle.zip')
21
23
  .option('-s, --chunk-size <size>', 'Maximum size per output file (e.g., "5MB", "25MB")', '5MB')
22
24
  .option('-f, --format <format>', 'Output format: xlsx (default) or docx', 'xlsx')
23
25
  .option('-p, --password <password>', 'Encryption password (optional, but recommended)')
24
26
  .option('--force', 'Overwrite existing files without asking')
25
- .option('--legacy', 'Use v4 format (hidden sheet + gzip) for backward compatibility')
27
+ .option('--v5', 'Emit the v5 format (PBKDF2) instead of the v6 default')
28
+ .option('--v6', 'Emit the v6 format (default; Argon2id, authenticated metadata)')
26
29
  .option('--no-limit', 'Bypass DOCX 1 MB size limit (large files will produce huge documents)')
27
30
  .option('-q, --quiet', 'Minimal output (for scripting)')
28
31
  .option('-y, --yes', 'Skip interactive prompts, use defaults')
29
- .action(async (file, options) => {
32
+ .action(async (inputs, options) => {
30
33
  try {
31
- await encodeCommand(file, options);
34
+ await encodeCommand(inputs, options);
32
35
  } catch (error) {
33
36
  console.error(chalk.red(`Error: ${error.message}`));
34
37
  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 };
@@ -9,40 +9,6 @@ const zlib = require('zlib');
9
9
  */
10
10
  const BROTLI_DEFAULT_QUALITY = 6;
11
11
 
12
- /**
13
- * Compress data using Brotli (used in v5+)
14
- * @param {Buffer} buffer - Data to compress
15
- * @param {number} [quality] - Compression quality (0-11, default 6)
16
- * @returns {Promise<Buffer>} Compressed data
17
- */
18
- function compressBrotli(buffer, quality) {
19
- const q = quality !== undefined ? quality : BROTLI_DEFAULT_QUALITY;
20
- return new Promise((resolve, reject) => {
21
- zlib.brotliCompress(buffer, {
22
- params: {
23
- [zlib.constants.BROTLI_PARAM_QUALITY]: q,
24
- },
25
- }, (err, result) => {
26
- if (err) reject(err);
27
- else resolve(result);
28
- });
29
- });
30
- }
31
-
32
- /**
33
- * Decompress Brotli data
34
- * @param {Buffer} buffer - Compressed data
35
- * @returns {Promise<Buffer>} Decompressed data
36
- */
37
- function decompressBrotli(buffer) {
38
- return new Promise((resolve, reject) => {
39
- zlib.brotliDecompress(buffer, (err, result) => {
40
- if (err) reject(err);
41
- else resolve(result);
42
- });
43
- });
44
- }
45
-
46
12
  /**
47
13
  * Create a streaming Brotli compression transform
48
14
  * @param {number} [quality] - Compression quality (0-11, default 6)
@@ -57,130 +23,4 @@ function createBrotliCompressStream(quality) {
57
23
  });
58
24
  }
59
25
 
60
- /**
61
- * Create a streaming Brotli decompression transform
62
- * @returns {zlib.BrotliDecompress} Brotli transform stream
63
- */
64
- function createBrotliDecompressStream() {
65
- return zlib.createBrotliDecompress();
66
- }
67
-
68
- // ─── Gzip Compression (v3/v4 legacy) ───────────────────────────────────────
69
-
70
- /**
71
- * MIME types that are already compressed - no benefit from additional compression
72
- */
73
- const COMPRESSED_MIMES = new Set([
74
- // Archives
75
- 'application/zip',
76
- 'application/x-7z-compressed',
77
- 'application/x-rar-compressed',
78
- 'application/gzip',
79
- 'application/x-gzip',
80
- 'application/x-bzip2',
81
- 'application/x-xz',
82
- 'application/x-tar',
83
- 'application/x-lzip',
84
- 'application/x-lzma',
85
- 'application/zstd',
86
-
87
- // Images (lossy compressed)
88
- 'image/jpeg',
89
- 'image/png',
90
- 'image/gif',
91
- 'image/webp',
92
- 'image/avif',
93
- 'image/heic',
94
- 'image/heif',
95
- 'image/jxl',
96
-
97
- // Audio
98
- 'audio/mpeg', // mp3
99
- 'audio/ogg',
100
- 'audio/flac',
101
- 'audio/aac',
102
- 'audio/mp4',
103
- 'audio/x-m4a',
104
- 'audio/opus',
105
-
106
- // Video
107
- 'video/mp4',
108
- 'video/webm',
109
- 'video/x-matroska', // mkv
110
- 'video/quicktime', // mov
111
- 'video/x-msvideo', // avi
112
- 'video/mpeg',
113
-
114
- // Documents (OOXML are zip-based)
115
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
116
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
117
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // pptx
118
- 'application/pdf',
119
- 'application/epub+zip',
120
- ]);
121
-
122
- /**
123
- * Check if a file type is already compressed
124
- * @param {string|null} mime - MIME type from file-type detection
125
- * @returns {boolean}
126
- */
127
- function isCompressedMime(mime) {
128
- return mime ? COMPRESSED_MIMES.has(mime) : false;
129
- }
130
-
131
- /**
132
- * Compress data using gzip
133
- * @param {Buffer} buffer - Data to compress
134
- * @returns {Promise<Buffer>} Compressed data
135
- */
136
- function compress(buffer) {
137
- return new Promise((resolve, reject) => {
138
- zlib.gzip(buffer, { level: 9 }, (err, result) => {
139
- if (err) reject(err);
140
- else resolve(result);
141
- });
142
- });
143
- }
144
-
145
- /**
146
- * Decompress gzip data
147
- * @param {Buffer} buffer - Compressed data
148
- * @returns {Promise<Buffer>} Decompressed data
149
- */
150
- function decompress(buffer) {
151
- return new Promise((resolve, reject) => {
152
- zlib.gunzip(buffer, (err, result) => {
153
- if (err) reject(err);
154
- else resolve(result);
155
- });
156
- });
157
- }
158
-
159
- /**
160
- * Create a streaming gzip compression transform
161
- * @returns {zlib.Gzip} Gzip transform stream
162
- */
163
- function createCompressStream() {
164
- return zlib.createGzip({ level: 9 });
165
- }
166
-
167
- /**
168
- * Create a streaming gunzip decompression transform
169
- * @returns {zlib.Gunzip} Gunzip transform stream
170
- */
171
- function createDecompressStream() {
172
- return zlib.createGunzip();
173
- }
174
-
175
- module.exports = {
176
- isCompressedMime,
177
- compress,
178
- decompress,
179
- createCompressStream,
180
- createDecompressStream,
181
- compressBrotli,
182
- decompressBrotli,
183
- createBrotliCompressStream,
184
- createBrotliDecompressStream,
185
- COMPRESSED_MIMES,
186
- };
26
+ module.exports = { createBrotliCompressStream };