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/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { program } = require('commander');
4
4
  const chalk = require('chalk');
5
+ const { version } = require('../package.json');
5
6
  const encodeCommand = require('./commands/encode');
6
7
  const decodeCommand = require('./commands/decode');
7
8
  const infoCommand = require('./commands/info');
@@ -11,24 +12,27 @@ const verifyCommand = require('./commands/verify');
11
12
  program
12
13
  .name('stegdoc')
13
14
  .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
- .version('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
27
  .option('--legacy', 'Use v4 format (hidden sheet + gzip) for backward compatibility')
28
+ .option('--v5', 'Emit the v5 format (PBKDF2) instead of the v6 default')
29
+ .option('--v6', 'Emit the v6 format (default; Argon2id, authenticated metadata)')
26
30
  .option('--no-limit', 'Bypass DOCX 1 MB size limit (large files will produce huge documents)')
27
31
  .option('-q, --quiet', 'Minimal output (for scripting)')
28
32
  .option('-y, --yes', 'Skip interactive prompts, use defaults')
29
- .action(async (file, options) => {
33
+ .action(async (inputs, options) => {
30
34
  try {
31
- await encodeCommand(file, options);
35
+ await encodeCommand(inputs, options);
32
36
  } catch (error) {
33
37
  console.error(chalk.red(`Error: ${error.message}`));
34
38
  process.exit(1);
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The napi binding target table, shared by the loader and the build scripts.
5
+ *
6
+ * The published package ships no binary. Each target becomes an optional
7
+ * dependency `@stegdoc/binding-<suffix>` carrying one `.node`, selected at
8
+ * require time by `src/lib/native.js`. The shape matches the ecosystem norm
9
+ * (`@swc/core-*`, `@rollup/rollup-*`), which is also what the airgap
10
+ * lockfile's `os`/`cpu`/`libc` filter expects.
11
+ *
12
+ * @typedef {object} BindingTarget
13
+ * @property {string} triple - Rust target triple.
14
+ * @property {string} suffix - npm name suffix, `<os>-<cpu>[-<libc>]`.
15
+ * @property {string} os - npm `os` field value.
16
+ * @property {string} cpu - npm `cpu` field value.
17
+ * @property {string} [libc] - npm `libc` field value.
18
+ * @property {string} lib - Artifact filename Cargo produces.
19
+ */
20
+
21
+ /** Scope the platform packages publish under. Changing it is a one-line rename. */
22
+ const SCOPE = '@stegdoc';
23
+
24
+ /** @type {BindingTarget[]} */
25
+ const TARGETS = [
26
+ {
27
+ triple: 'x86_64-pc-windows-msvc',
28
+ suffix: 'win32-x64-msvc',
29
+ os: 'win32',
30
+ cpu: 'x64',
31
+ lib: 'stegdoc_node.dll',
32
+ },
33
+ {
34
+ triple: 'x86_64-unknown-linux-gnu',
35
+ suffix: 'linux-x64-gnu',
36
+ os: 'linux',
37
+ cpu: 'x64',
38
+ libc: 'glibc',
39
+ lib: 'libstegdoc_node.so',
40
+ },
41
+ {
42
+ triple: 'aarch64-unknown-linux-gnu',
43
+ suffix: 'linux-arm64-gnu',
44
+ os: 'linux',
45
+ cpu: 'arm64',
46
+ libc: 'glibc',
47
+ lib: 'libstegdoc_node.so',
48
+ },
49
+ {
50
+ triple: 'x86_64-apple-darwin',
51
+ suffix: 'darwin-x64',
52
+ os: 'darwin',
53
+ cpu: 'x64',
54
+ lib: 'libstegdoc_node.dylib',
55
+ },
56
+ {
57
+ triple: 'aarch64-apple-darwin',
58
+ suffix: 'darwin-arm64',
59
+ os: 'darwin',
60
+ cpu: 'arm64',
61
+ lib: 'libstegdoc_node.dylib',
62
+ },
63
+ ];
64
+
65
+ /**
66
+ * The npm package name for a target.
67
+ * @param {BindingTarget} target
68
+ * @returns {string}
69
+ */
70
+ function packageName(target) {
71
+ return `${SCOPE}/binding-${target.suffix}`;
72
+ }
73
+
74
+ /**
75
+ * Whether the running Linux is musl-based. No musl package is published, so
76
+ * the loader must not pick the glibc binary for it.
77
+ * @returns {boolean}
78
+ */
79
+ function isMusl() {
80
+ if (process.platform !== 'linux') return false;
81
+ try {
82
+ return !process.report.getReport().header.glibcVersionRuntime;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The target matching the running platform, or null when none is published.
90
+ * @returns {BindingTarget|null}
91
+ */
92
+ function hostTarget() {
93
+ if (isMusl()) return null;
94
+ return (
95
+ TARGETS.find((target) => target.os === process.platform && target.cpu === process.arch) || null
96
+ );
97
+ }
98
+
99
+ module.exports = { SCOPE, TARGETS, packageName, hostTarget };
@@ -657,9 +657,12 @@ function decodeLogLines(allRows) {
657
657
  const headerStr = headerPayload.toString('utf8');
658
658
 
659
659
  // Parse header: "STGD05|<metaLen>|<encLen>|{metadataJson}{encryptionMeta}"
660
- const markerIdx = headerStr.indexOf('STGD05|');
660
+ const markerIdx = headerStr.search(/STGD0[56]\|/);
661
661
  if (markerIdx === -1) {
662
- throw new Error('Invalid v5 format: magic marker not found. This may not be a stegdoc v5 file.');
662
+ throw new Error('Invalid log-embed format: magic marker not found. This may not be a stegdoc file.');
663
+ }
664
+ if (headerStr.startsWith('STGD06|', markerIdx)) {
665
+ throw new Error('This file uses the v6 format, which the JavaScript decoder cannot read. The native engine is required.');
663
666
  }
664
667
 
665
668
  const afterMarker = headerStr.slice(markerIdx + 7);
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { hostTarget, packageName } = require('./bindings');
6
+
7
+ const NATIVE_DIR = path.join(__dirname, '..', '..', 'native');
8
+
9
+ let binding;
10
+ let resolved = false;
11
+
12
+ /**
13
+ * Load the native format engine, or return null when it is not available.
14
+ *
15
+ * Resolution order:
16
+ * 1. `STEGDOC_NATIVE_PATH`, an explicit `.node` file.
17
+ * 2. The platform package (`@stegdoc/binding-<suffix>`) from the published
18
+ * install's optionalDependencies.
19
+ * 3. `native/stegdoc.node`, the local `pnpm build:native` output.
20
+ *
21
+ * The binding is required for log-embed (v5/v6) decode and is the preferred
22
+ * encoder. The CommonJS implementation remains only for the legacy v3/v4
23
+ * formats, which the native engine does not cover. Set
24
+ * `STEGDOC_DISABLE_NATIVE=1` to force the JS paths.
25
+ *
26
+ * @returns {object|null} The binding, or null when unavailable.
27
+ */
28
+ function loadNative() {
29
+ if (resolved) return binding;
30
+ resolved = true;
31
+
32
+ if (process.env.STEGDOC_DISABLE_NATIVE === '1') {
33
+ binding = null;
34
+ return binding;
35
+ }
36
+
37
+ const explicit = process.env.STEGDOC_NATIVE_PATH;
38
+ if (explicit) {
39
+ binding = require(explicit);
40
+ return binding;
41
+ }
42
+
43
+ const target = hostTarget();
44
+ if (target) {
45
+ try {
46
+ binding = require(packageName(target));
47
+ return binding;
48
+ } catch {
49
+ // Not installed; fall through to the development build.
50
+ }
51
+ }
52
+
53
+ try {
54
+ binding = require(path.join(NATIVE_DIR, 'stegdoc.node'));
55
+ } catch {
56
+ binding = null;
57
+ }
58
+ return binding;
59
+ }
60
+
61
+ /**
62
+ * @returns {boolean} Whether the native engine is loaded.
63
+ */
64
+ function nativeAvailable() {
65
+ return loadNative() !== null;
66
+ }
67
+
68
+ module.exports = { loadNative, nativeAvailable };
@@ -1,65 +1,6 @@
1
1
  const { Transform, Writable } = require('stream');
2
2
  const crypto = require('crypto');
3
3
 
4
- /**
5
- * Transform stream that converts binary input to base64 text output.
6
- * Buffers incomplete 3-byte groups across chunk boundaries.
7
- */
8
- class Base64EncodeTransform extends Transform {
9
- constructor() {
10
- super();
11
- this._remainder = Buffer.alloc(0);
12
- }
13
-
14
- _transform(chunk, encoding, callback) {
15
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
16
- const combined = this._remainder.length > 0 ? Buffer.concat([this._remainder, buf]) : buf;
17
- const usable = combined.length - (combined.length % 3);
18
- if (usable > 0) {
19
- this.push(combined.slice(0, usable).toString('base64'));
20
- }
21
- this._remainder = usable < combined.length ? combined.slice(usable) : Buffer.alloc(0);
22
- callback();
23
- }
24
-
25
- _flush(callback) {
26
- if (this._remainder.length > 0) {
27
- this.push(this._remainder.toString('base64'));
28
- this._remainder = Buffer.alloc(0);
29
- }
30
- callback();
31
- }
32
- }
33
-
34
- /**
35
- * Transform stream that converts base64 text input to binary output.
36
- * Buffers incomplete 4-char groups across chunk boundaries.
37
- */
38
- class Base64DecodeTransform extends Transform {
39
- constructor() {
40
- super();
41
- this._remainder = '';
42
- }
43
-
44
- _transform(chunk, encoding, callback) {
45
- const str = this._remainder + (Buffer.isBuffer(chunk) ? chunk.toString() : chunk);
46
- const usable = str.length - (str.length % 4);
47
- if (usable > 0) {
48
- this.push(Buffer.from(str.slice(0, usable), 'base64'));
49
- }
50
- this._remainder = usable < str.length ? str.slice(usable) : '';
51
- callback();
52
- }
53
-
54
- _flush(callback) {
55
- if (this._remainder.length > 0) {
56
- this.push(Buffer.from(this._remainder, 'base64'));
57
- this._remainder = '';
58
- }
59
- callback();
60
- }
61
- }
62
-
63
4
  /**
64
5
  * Transform stream that passes data through unchanged while computing SHA-256 hash.
65
6
  * Access the hex hash via .digest after the stream has ended.
@@ -90,51 +31,6 @@ class HashPassthrough extends Transform {
90
31
  }
91
32
  }
92
33
 
93
- /**
94
- * Writable stream that collects string output up to maxBytes.
95
- * Calls an async onChunkReady callback when a chunk is full, applying
96
- * backpressure to pause upstream until the callback resolves.
97
- */
98
- class ChunkCollector extends Writable {
99
- constructor(maxBytes, onChunkReady) {
100
- super({ decodeStrings: false });
101
- this._maxBytes = maxBytes;
102
- this._buffer = '';
103
- this._chunkIndex = 0;
104
- this._onChunkReady = onChunkReady;
105
- }
106
-
107
- async _write(chunk, encoding, callback) {
108
- try {
109
- this._buffer += typeof chunk === 'string' ? chunk : chunk.toString();
110
- while (this._buffer.length >= this._maxBytes) {
111
- const piece = this._buffer.slice(0, this._maxBytes);
112
- this._buffer = this._buffer.slice(this._maxBytes);
113
- await this._onChunkReady(piece, this._chunkIndex++);
114
- }
115
- callback();
116
- } catch (err) {
117
- callback(err);
118
- }
119
- }
120
-
121
- async _final(callback) {
122
- try {
123
- if (this._buffer.length > 0) {
124
- await this._onChunkReady(this._buffer, this._chunkIndex++);
125
- this._buffer = '';
126
- }
127
- callback();
128
- } catch (err) {
129
- callback(err);
130
- }
131
- }
132
-
133
- get totalChunks() {
134
- return this._chunkIndex;
135
- }
136
- }
137
-
138
34
  /**
139
35
  * Writable stream that collects binary Buffer output up to maxBytes.
140
36
  * Calls an async onChunkReady callback with a Buffer when full.
@@ -221,10 +117,7 @@ class ProgressTransform extends Transform {
221
117
  }
222
118
 
223
119
  module.exports = {
224
- Base64EncodeTransform,
225
- Base64DecodeTransform,
226
120
  HashPassthrough,
227
- ChunkCollector,
228
121
  BinaryChunkCollector,
229
122
  ProgressTransform,
230
123
  };
package/src/lib/utils.js CHANGED
@@ -213,6 +213,25 @@ function detectFormat(filename) {
213
213
  return null;
214
214
  }
215
215
 
216
+ /**
217
+ * Reduce a metadata-supplied name to a bare, safe filename.
218
+ *
219
+ * `originalFilename` is unauthenticated, so it is never joined onto a directory
220
+ * unchecked (FORMAT.md 11.1). Any directory component is discarded and the
221
+ * remainder must not be empty, `.`, `..`, or contain NUL.
222
+ *
223
+ * @param {string} name - Metadata-supplied filename
224
+ * @returns {string} A bare filename safe to join onto a directory
225
+ * @throws {Error} If the name has no usable filename component
226
+ */
227
+ function safeFilename(name) {
228
+ const last = String(name == null ? '' : name).split(/[/\\]/).pop();
229
+ if (!last || last === '.' || last === '..' || last.includes('\0')) {
230
+ throw new Error(`Unsafe output filename in metadata: ${JSON.stringify(name)}`);
231
+ }
232
+ return last;
233
+ }
234
+
216
235
  module.exports = {
217
236
  generateHash,
218
237
  generateContentHash,
@@ -221,6 +240,7 @@ module.exports = {
221
240
  generateFilename,
222
241
  parseFilename,
223
242
  detectFormat,
243
+ safeFilename,
224
244
  // Legacy aliases for backward compatibility
225
245
  generateDocxFilename,
226
246
  parseDocxFilename,
@@ -295,4 +295,14 @@ module.exports = {
295
295
  buildSheetXml,
296
296
  escapeXml,
297
297
  colLetter,
298
+ // Raw OOXML parts, exported so spec/tools/generate-xlsx-static.js can emit
299
+ // the Rust writer's constants from this frozen implementation.
300
+ CONTENT_TYPES,
301
+ RELS,
302
+ WORKBOOK_RELS,
303
+ STYLES,
304
+ THEME,
305
+ makeWorkbook,
306
+ makeCoreProps,
307
+ makeAppProps,
298
308
  };
@@ -1,113 +0,0 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
-
4
- /**
5
- * Read a file and encode it to base64
6
- * @param {string} filePath - Path to the file
7
- * @returns {object} Object containing base64 string, filename, extension, and size
8
- */
9
- function encodeFileToBase64(filePath) {
10
- if (!fs.existsSync(filePath)) {
11
- throw new Error(`File not found: ${filePath}`);
12
- }
13
-
14
- const stats = fs.statSync(filePath);
15
-
16
- if (!stats.isFile()) {
17
- throw new Error(`Path is not a file: ${filePath}`);
18
- }
19
-
20
- const fileBuffer = fs.readFileSync(filePath);
21
- const base64 = fileBuffer.toString('base64');
22
- const filename = path.basename(filePath);
23
- const extension = path.extname(filePath);
24
-
25
- return {
26
- base64,
27
- filename,
28
- extension,
29
- size: stats.size,
30
- };
31
- }
32
-
33
- /**
34
- * Decode base64 string and write to file
35
- * @param {string} base64 - Base64 encoded string
36
- * @param {string} outputPath - Output file path
37
- */
38
- function decodeBase64ToFile(base64, outputPath) {
39
- const buffer = Buffer.from(base64, 'base64');
40
-
41
- // Ensure output directory exists
42
- const outputDir = path.dirname(outputPath);
43
- if (!fs.existsSync(outputDir)) {
44
- fs.mkdirSync(outputDir, { recursive: true });
45
- }
46
-
47
- fs.writeFileSync(outputPath, buffer);
48
- }
49
-
50
- /**
51
- * Split base64 string into chunks based on size limit
52
- * @param {string} base64 - Base64 string to split
53
- * @param {number} chunkSizeBytes - Maximum size per chunk in bytes
54
- * @returns {Array<string>} Array of base64 chunks
55
- */
56
- function splitBase64(base64, chunkSizeBytes) {
57
- const chunks = [];
58
- let offset = 0;
59
-
60
- while (offset < base64.length) {
61
- chunks.push(base64.slice(offset, offset + chunkSizeBytes));
62
- offset += chunkSizeBytes;
63
- }
64
-
65
- return chunks;
66
- }
67
-
68
- /**
69
- * Merge base64 chunks back into a single string
70
- * @param {Array<string>} chunks - Array of base64 chunks
71
- * @returns {string} Merged base64 string
72
- */
73
- function mergeBase64Chunks(chunks) {
74
- return chunks.join('');
75
- }
76
-
77
- /**
78
- * Calculate how many chunks will be needed for a file
79
- * @param {number} fileSize - File size in bytes
80
- * @param {number} chunkSizeBytes - Chunk size in bytes
81
- * @returns {number} Number of chunks needed
82
- */
83
- function calculateChunkCount(fileSize, chunkSizeBytes) {
84
- // Base64 encoding increases size by ~33%
85
- const base64Size = Math.ceil(fileSize * 4 / 3);
86
- return Math.ceil(base64Size / chunkSizeBytes);
87
- }
88
-
89
- /**
90
- * Validate if a path is writable
91
- * @param {string} dirPath - Directory path to check
92
- * @returns {boolean} True if writable
93
- */
94
- function isDirectoryWritable(dirPath) {
95
- try {
96
- if (!fs.existsSync(dirPath)) {
97
- fs.mkdirSync(dirPath, { recursive: true });
98
- }
99
- fs.accessSync(dirPath, fs.constants.W_OK);
100
- return true;
101
- } catch (error) {
102
- return false;
103
- }
104
- }
105
-
106
- module.exports = {
107
- encodeFileToBase64,
108
- decodeBase64ToFile,
109
- splitBase64,
110
- mergeBase64Chunks,
111
- calculateChunkCount,
112
- isDirectoryWritable,
113
- };