stegdoc 5.2.0 → 5.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stegdoc",
3
- "version": "5.2.0",
3
+ "version": "5.4.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": {
@@ -198,7 +198,8 @@ async function decodeV5(inputFile, format, firstReadResult, options, spinner, qu
198
198
  spinner.succeed && spinner.succeed(`Found all ${totalPartsFound} parts`);
199
199
 
200
200
  for (let i = 0; i < allParts.length; i++) {
201
- const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1} of ${totalPartsFound}...`).start();
201
+ const pct = Math.round(((i + 1) / totalPartsFound) * 100);
202
+ const partSpinner = quiet ? spinner : ora(`Decoding part ${i + 1}/${totalPartsFound} (${pct}%)...`).start();
202
203
 
203
204
  const partResult = await readFile(allParts[i].path, format);
204
205
 
@@ -228,7 +229,7 @@ async function decodeV5(inputFile, format, firstReadResult, options, spinner, qu
228
229
  writeTarget.write(partPayload);
229
230
  }
230
231
 
231
- partSpinner.succeed && partSpinner.succeed(`Part ${i + 1} decoded`);
232
+ partSpinner.succeed && partSpinner.succeed(`Part ${i + 1}/${totalPartsFound} decoded`);
232
233
  }
233
234
  } else {
234
235
  // Single file
@@ -178,16 +178,52 @@ async function encodeCommand(inputFile, options) {
178
178
  // Generate session salt for encryption
179
179
  const sessionSalt = useEncryption ? generateSalt() : null;
180
180
 
181
- spinner.text = useCompression ? 'Compressing (Brotli) and encoding...' : 'Encoding...';
181
+ // === Phase 1: Compress and collect all chunks ===
182
+ spinner.text = useCompression ? 'Compressing...' : 'Reading...';
182
183
 
183
184
  const partFiles = [];
184
-
185
- // v5 pipeline: compress (brotli) → collect binary chunks → encrypt per-part → embed in log lines
186
185
  const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
187
186
 
188
- const onBinaryChunkReady = async (binaryBuffer, index) => {
187
+ // Collect all chunks first (enables parallel part creation + known total)
188
+ const collectedChunks = [];
189
+ const collectChunk = async (binaryBuffer, index) => {
190
+ collectedChunks.push({ buffer: Buffer.from(binaryBuffer), index });
191
+ };
192
+
193
+ const collector = new BinaryChunkCollector(binaryChunkSize, collectChunk);
194
+
195
+ const streams = [fs.createReadStream(streamSource)];
196
+
197
+ // Add progress tracking (tracks input bytes read)
198
+ if (!quiet) {
199
+ const progressStream = new ProgressTransform(fileSize, (processed, total) => {
200
+ const pct = Math.min(100, Math.round((processed / total) * 100));
201
+ const processedFmt = formatBytes(processed);
202
+ const totalFmt = formatBytes(total);
203
+ const phase = useCompression ? 'Compressing' : 'Reading';
204
+ spinner.text = `${phase}... ${processedFmt} / ${totalFmt} (${pct}%)`;
205
+ });
206
+ streams.push(progressStream);
207
+ }
208
+
209
+ if (useCompression) {
210
+ streams.push(createBrotliCompressStream());
211
+ }
212
+ streams.push(collector);
213
+
214
+ await pipeline(...streams);
215
+
216
+ const totalParts = collectedChunks.length;
217
+ spinner.succeed && spinner.succeed(`${useCompression ? 'Compressed' : 'Read'}: ${totalParts} part${totalParts !== 1 ? 's' : ''} to create`);
218
+
219
+ // === Phase 2: Create parts in parallel ===
220
+ const os = require('os');
221
+ const concurrency = Math.min(totalParts, Math.max(1, os.cpus().length));
222
+ let completedParts = 0;
223
+ const partSpinner = quiet ? spinner : ora(`Creating parts... 0/${totalParts}`).start();
224
+
225
+ const createPart = async ({ buffer: binaryBuffer, index }) => {
189
226
  const partNumber = index + 1;
190
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
191
227
 
192
228
  let payloadBuffer;
193
229
  let encryptionMeta = '';
@@ -208,7 +244,7 @@ async function encodeCommand(inputFile, options) {
208
244
  originalExtension: extension,
209
245
  hash,
210
246
  partNumber,
211
- totalParts: null,
247
+ totalParts,
212
248
  originalSize: fileSize,
213
249
  format,
214
250
  encrypted: useEncryption,
@@ -262,35 +298,26 @@ async function encodeCommand(inputFile, options) {
262
298
 
263
299
  partFiles.push(outputPath);
264
300
  createdFiles.push(outputPath);
265
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(payloadBuffer.length)} payload, ${dataLineCount} data lines)`);
266
- };
267
-
268
- const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
269
301
 
270
- const streams = [fs.createReadStream(streamSource)];
302
+ completedParts++;
303
+ const pct = Math.round((completedParts / totalParts) * 100);
304
+ partSpinner.text = `Creating parts... ${completedParts}/${totalParts} (${pct}%)`;
305
+ };
271
306
 
272
- // Add progress tracking before compression (tracks input bytes read)
273
- if (!quiet) {
274
- const progressStream = new ProgressTransform(fileSize, (processed, total) => {
275
- const pct = Math.min(100, Math.round((processed / total) * 100));
276
- const processedFmt = formatBytes(processed);
277
- const totalFmt = formatBytes(total);
278
- const phase = useCompression ? 'Compressing' : 'Processing';
279
- spinner.text = `${phase}... ${processedFmt} / ${totalFmt} (${pct}%)`;
280
- });
281
- streams.push(progressStream);
282
- }
307
+ // Run part creation in parallel with limited concurrency
308
+ const chunks = [...collectedChunks];
309
+ const executing = new Set();
283
310
 
284
- if (useCompression) {
285
- streams.push(createBrotliCompressStream());
311
+ for (const chunk of chunks) {
312
+ const promise = createPart(chunk).then(() => executing.delete(promise));
313
+ executing.add(promise);
314
+ if (executing.size >= concurrency) {
315
+ await Promise.race(executing);
316
+ }
286
317
  }
287
- streams.push(collector);
288
-
289
- await pipeline(...streams);
290
-
291
- const totalParts = partFiles.length;
318
+ await Promise.all(executing);
292
319
 
293
- spinner.succeed && spinner.succeed('Encoding complete!');
320
+ partSpinner.succeed && partSpinner.succeed(`Created ${totalParts} part${totalParts !== 1 ? 's' : ''}`);
294
321
 
295
322
  if (!quiet) {
296
323
  console.log();
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ const verifyCommand = require('./commands/verify');
11
11
  program
12
12
  .name('stegdoc')
13
13
  .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
- .version('5.2.0');
14
+ .version('5.4.0');
15
15
 
16
16
  // Encode command
17
17
  program
@@ -117,6 +117,7 @@ async function createXlsxPartV5(options) {
117
117
 
118
118
  /**
119
119
  * Read a v5 log-embed XLSX file and extract payload.
120
+ * Uses fast regex-based XML scanning instead of full DOM parsing for speed.
120
121
  * @param {string} xlsxPath - Path to XLSX file
121
122
  * @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata }
122
123
  */
@@ -125,62 +126,67 @@ async function readXlsxV5(xlsxPath) {
125
126
  throw new Error(`XLSX file not found: ${xlsxPath}`);
126
127
  }
127
128
 
128
- // Parse shared strings
129
- let sharedStrings = [];
130
- const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
131
- if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
132
- const siArray = ensureArray(ssParsed.sst.si);
133
- sharedStrings = siArray.map((si) => extractTextContent(si.t));
129
+ // Read file once and reuse the zip instance
130
+ const fileBuffer = fs.readFileSync(xlsxPath);
131
+ const zip = new AdmZip(fileBuffer);
132
+
133
+ // Parse shared strings (only if present — v5 files created with useSharedStrings:false may not have them)
134
+ let sharedStrings = null;
135
+ const ssEntry = zip.getEntry('xl/sharedStrings.xml');
136
+ if (ssEntry) {
137
+ sharedStrings = [];
138
+ const ssXml = ssEntry.getData().toString('utf8');
139
+ // Fast extract: match each <si><t>...</t></si> or <si><t ...>...</t></si>
140
+ const siRegex = /<si><t[^>]*>([^<]*)<\/t><\/si>/g;
141
+ let match;
142
+ while ((match = siRegex.exec(ssXml)) !== null) {
143
+ sharedStrings.push(match[1]);
144
+ }
134
145
  }
135
146
 
136
- // Parse sheet1 (the only sheet in v5)
137
- const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet1.xml');
138
- if (!sheetParsed) {
147
+ // Extract sheet1.xml raw string
148
+ const sheetEntry = zip.getEntry('xl/worksheets/sheet1.xml');
149
+ if (!sheetEntry) {
139
150
  throw new Error('Sheet not found in XLSX file.');
140
151
  }
152
+ const sheetXml = sheetEntry.getData().toString('utf8');
141
153
 
142
- // Extract all rows as arrays of cell values
154
+ // Fast row extraction using regex much faster than full DOM parsing
143
155
  const allRows = [];
144
- const sheetData = sheetParsed.worksheet?.sheetData;
145
-
146
- if (sheetData && sheetData.row) {
147
- const rows = ensureArray(sheetData.row);
148
-
149
- for (const row of rows) {
150
- if (!row.c) continue;
151
- const cells = ensureArray(row.c);
152
-
153
- // Build a sparse array for this row
154
- const rowValues = [];
155
- for (const cell of cells) {
156
- const cellRef = cell['@_r'];
157
- if (!cellRef) continue;
158
-
159
- // Parse column index from cell reference (e.g., "A2" -> col 0, "J2" -> col 9)
160
- const colMatch = cellRef.match(/^([A-Z]+)/);
161
- if (!colMatch) continue;
162
- const colIdx = colLetterToIndex(colMatch[1]);
163
-
164
- const cellType = cell['@_t'];
165
- const cellValue = cell.v;
166
-
167
- let value;
168
- if (cellType === 's' && cellValue !== undefined) {
169
- const ssIndex = parseInt(cellValue, 10);
170
- value = ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : '';
171
- } else if (cellType === 'inlineStr' && cell.is) {
172
- value = extractTextContent(cell.is.t);
173
- } else if (cellValue !== undefined) {
174
- value = String(cellValue);
175
- } else {
176
- value = '';
177
- }
178
-
179
- rowValues[colIdx] = value;
156
+ const rowRegex = /<row [^>]*>(.*?)<\/row>/gs;
157
+ const cellRegex = /<c r="([A-Z]+)\d+"(?: t="([^"]*)")?[^>]*>(?:<v>([^<]*)<\/v>|<is><t[^>]*>([^<]*)<\/t><\/is>)?<\/c>/g;
158
+
159
+ let rowMatch;
160
+ while ((rowMatch = rowRegex.exec(sheetXml)) !== null) {
161
+ const rowXml = rowMatch[1];
162
+ const rowValues = [];
163
+
164
+ let cellMatch;
165
+ cellRegex.lastIndex = 0;
166
+ while ((cellMatch = cellRegex.exec(rowXml)) !== null) {
167
+ const colLetter = cellMatch[1];
168
+ const cellType = cellMatch[2] || '';
169
+ const vValue = cellMatch[3];
170
+ const inlineValue = cellMatch[4];
171
+
172
+ const colIdx = colLetterToIndex(colLetter);
173
+
174
+ let value;
175
+ if (cellType === 's' && vValue !== undefined && sharedStrings) {
176
+ const ssIndex = parseInt(vValue, 10);
177
+ value = ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : '';
178
+ } else if (inlineValue !== undefined) {
179
+ value = decodeXmlEntities(inlineValue);
180
+ } else if (vValue !== undefined) {
181
+ value = decodeXmlEntities(vValue);
182
+ } else {
183
+ value = '';
180
184
  }
181
185
 
182
- allRows.push(rowValues);
186
+ rowValues[colIdx] = value;
183
187
  }
188
+
189
+ allRows.push(rowValues);
184
190
  }
185
191
 
186
192
  // Skip the first row (column headers)
@@ -194,6 +200,19 @@ async function readXlsxV5(xlsxPath) {
194
200
  return decodeLogLines(dataRows);
195
201
  }
196
202
 
203
+ /**
204
+ * Decode XML entities in a string value
205
+ */
206
+ function decodeXmlEntities(str) {
207
+ if (!str || !str.includes('&')) return str;
208
+ return str
209
+ .replace(/&amp;/g, '&')
210
+ .replace(/&lt;/g, '<')
211
+ .replace(/&gt;/g, '>')
212
+ .replace(/&apos;/g, "'")
213
+ .replace(/&quot;/g, '"');
214
+ }
215
+
197
216
  /**
198
217
  * Convert column letter to 0-based index (A=0, B=1, ..., Z=25, AA=26)
199
218
  */