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.
@@ -24,15 +24,11 @@ function createMetadata({
24
24
  encrypted = true,
25
25
  compressed = false,
26
26
  contentHash = null,
27
- // v5 fields
28
- stegoMethod = null,
29
27
  compressionAlgo = null,
30
28
  payloadSize = null,
31
29
  dataLineCount = null,
32
30
  headerLineCount = null,
33
31
  }) {
34
- const isV5 = stegoMethod === 'log-embed';
35
-
36
32
  const meta = {
37
33
  originalFilename,
38
34
  originalExtension,
@@ -44,20 +40,17 @@ function createMetadata({
44
40
  encrypted,
45
41
  compressed,
46
42
  contentHash,
47
- pipelineOrder: isV5 ? 'brotli-encrypt-logEmbed' : 'compress-encrypt-base64',
43
+ pipelineOrder: 'brotli-encrypt-logEmbed',
48
44
  encodingDate: new Date().toISOString(),
49
- version: isV5 ? '5.0.0' : '4.0.0',
45
+ version: '5.0.0',
50
46
  tool: 'stegdoc',
47
+ stegoMethod: 'log-embed',
48
+ compressionAlgo: compressionAlgo || 'brotli',
51
49
  };
52
50
 
53
- // v5-specific fields
54
- if (isV5) {
55
- meta.stegoMethod = 'log-embed';
56
- meta.compressionAlgo = compressionAlgo || 'brotli';
57
- if (payloadSize !== null) meta.payloadSize = payloadSize;
58
- if (dataLineCount !== null) meta.dataLineCount = dataLineCount;
59
- if (headerLineCount !== null) meta.headerLineCount = headerLineCount;
60
- }
51
+ if (payloadSize !== null) meta.payloadSize = payloadSize;
52
+ if (dataLineCount !== null) meta.dataLineCount = dataLineCount;
53
+ if (headerLineCount !== null) meta.headerLineCount = headerLineCount;
61
54
 
62
55
  return meta;
63
56
  }
@@ -71,81 +64,7 @@ function serializeMetadata(metadata) {
71
64
  return JSON.stringify(metadata);
72
65
  }
73
66
 
74
- /**
75
- * Parse metadata from string
76
- * @param {string} metadataStr - JSON string
77
- * @returns {object} Metadata object
78
- */
79
- function parseMetadata(metadataStr) {
80
- try {
81
- return JSON.parse(metadataStr);
82
- } catch (error) {
83
- throw new Error(`Failed to parse metadata: ${error.message}`);
84
- }
85
- }
86
-
87
- /**
88
- * Validate metadata object
89
- * @param {object} metadata - Metadata to validate
90
- * @returns {boolean} True if valid
91
- * @throws {Error} If metadata is invalid
92
- */
93
- function validateMetadata(metadata) {
94
- const required = ['originalFilename', 'originalExtension', 'hash', 'tool'];
95
-
96
- for (const field of required) {
97
- if (!metadata[field]) {
98
- throw new Error(`Missing required metadata field: ${field}`);
99
- }
100
- }
101
-
102
- if (metadata.tool !== 'stegdoc' && metadata.tool !== 'whitener') {
103
- throw new Error('Invalid tool identifier in metadata');
104
- }
105
-
106
- // If it's a multi-part file, validate part info
107
- if (metadata.totalParts !== null && metadata.totalParts > 1) {
108
- if (metadata.partNumber === null || metadata.partNumber < 1 || metadata.partNumber > metadata.totalParts) {
109
- throw new Error('Invalid part number in metadata');
110
- }
111
- }
112
-
113
- return true;
114
- }
115
-
116
- /**
117
- * Check if metadata indicates a multi-part file
118
- * @param {object} metadata - Metadata object
119
- * @returns {boolean} True if multi-part
120
- */
121
- function isMultiPart(metadata) {
122
- return metadata.totalParts !== null && metadata.totalParts > 1;
123
- }
124
-
125
- /**
126
- * Check if metadata indicates the v4 streaming format
127
- * @param {object} metadata - Metadata object
128
- * @returns {boolean} True if streaming format (v4+)
129
- */
130
- function isStreamingFormat(metadata) {
131
- return metadata.pipelineOrder === 'compress-encrypt-base64';
132
- }
133
-
134
- /**
135
- * Check if metadata indicates the v5 log-embed format
136
- * @param {object} metadata - Metadata object
137
- * @returns {boolean} True if log-embed format (v5+)
138
- */
139
- function isLogEmbedFormat(metadata) {
140
- return metadata.stegoMethod === 'log-embed' || metadata.pipelineOrder === 'brotli-encrypt-logEmbed';
141
- }
142
-
143
67
  module.exports = {
144
68
  createMetadata,
145
69
  serializeMetadata,
146
- parseMetadata,
147
- validateMetadata,
148
- isMultiPart,
149
- isStreamingFormat,
150
- isLogEmbedFormat,
151
70
  };
package/src/lib/native.js CHANGED
@@ -19,9 +19,9 @@ let resolved = false;
19
19
  * 3. `native/stegdoc.node`, the local `pnpm build:native` output.
20
20
  *
21
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.
22
+ * encoder. The CommonJS v5 encoder survives only as a fallback for hosts where
23
+ * the binding cannot be loaded. Set `STEGDOC_DISABLE_NATIVE=1` to force that
24
+ * fallback; it does not affect decode, which has no JS path.
25
25
  *
26
26
  * @returns {object|null} The binding, or null when unavailable.
27
27
  */
@@ -1,35 +1,4 @@
1
1
  const { Transform, Writable } = require('stream');
2
- const crypto = require('crypto');
3
-
4
- /**
5
- * Transform stream that passes data through unchanged while computing SHA-256 hash.
6
- * Access the hex hash via .digest after the stream has ended.
7
- */
8
- class HashPassthrough extends Transform {
9
- constructor() {
10
- super();
11
- this._hash = crypto.createHash('sha256');
12
- this._finalized = false;
13
- }
14
-
15
- _transform(chunk, encoding, callback) {
16
- this._hash.update(chunk);
17
- this.push(chunk);
18
- callback();
19
- }
20
-
21
- _flush(callback) {
22
- this._finalized = true;
23
- callback();
24
- }
25
-
26
- get digest() {
27
- if (!this._finalized) {
28
- throw new Error('Cannot read digest before stream has ended');
29
- }
30
- return this._hash.digest('hex');
31
- }
32
- }
33
2
 
34
3
  /**
35
4
  * Writable stream that collects binary Buffer output up to maxBytes.
@@ -117,7 +86,6 @@ class ProgressTransform extends Transform {
117
86
  }
118
87
 
119
88
  module.exports = {
120
- HashPassthrough,
121
89
  BinaryChunkCollector,
122
90
  ProgressTransform,
123
91
  };
package/src/lib/utils.js CHANGED
@@ -9,15 +9,6 @@ function generateHash(length = 8) {
9
9
  return crypto.randomBytes(length).toString('hex');
10
10
  }
11
11
 
12
- /**
13
- * Generate SHA-256 hash of a buffer for integrity verification
14
- * @param {Buffer} buffer - Data to hash
15
- * @returns {string} Hexadecimal SHA-256 hash
16
- */
17
- function generateContentHash(buffer) {
18
- return crypto.createHash('sha256').update(buffer).digest('hex');
19
- }
20
-
21
12
  /**
22
13
  * Parse size string to bytes
23
14
  * @param {string} sizeStr - Size string (e.g., "5MB", "100KB", "1GB")
@@ -113,13 +104,6 @@ function generateFilename(hash, partNumber = null, totalParts = null, format = '
113
104
  }
114
105
  }
115
106
 
116
- /**
117
- * Legacy alias for backward compatibility
118
- */
119
- function generateDocxFilename(hash, partNumber = null, totalParts = null) {
120
- return generateFilename(hash, partNumber, totalParts, 'docx').replace(/\.docx$/, '');
121
- }
122
-
123
107
  /**
124
108
  * Parse filename to extract hash, part information, and format
125
109
  * @param {string} filename - Filename to parse
@@ -195,13 +179,6 @@ function parseFilename(filename) {
195
179
  return null;
196
180
  }
197
181
 
198
- /**
199
- * Legacy alias for backward compatibility
200
- */
201
- function parseDocxFilename(filename) {
202
- return parseFilename(filename);
203
- }
204
-
205
182
  /**
206
183
  * Detect file format from extension
207
184
  * @param {string} filename - Filename to check
@@ -213,35 +190,11 @@ function detectFormat(filename) {
213
190
  return null;
214
191
  }
215
192
 
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
-
235
193
  module.exports = {
236
194
  generateHash,
237
- generateContentHash,
238
195
  parseSizeToBytes,
239
196
  formatBytes,
240
197
  generateFilename,
241
198
  parseFilename,
242
199
  detectFormat,
243
- safeFilename,
244
- // Legacy aliases for backward compatibility
245
- generateDocxFilename,
246
- parseDocxFilename,
247
200
  };
@@ -1,8 +1,4 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const AdmZip = require('adm-zip');
4
- const { generateLogHeaders, encodePayloadToLogLines, decodeLogLines, resetTimeState } = require('./log-generator');
5
- const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
1
+ const { generateLogHeaders, encodePayloadToLogLines } = require('./log-generator');
6
2
  const { createXlsxRaw } = require('./xlsx-writer');
7
3
 
8
4
  const V5_SHEET_NAME = 'Access Logs';
@@ -45,273 +41,6 @@ async function createXlsxPartV5(options) {
45
41
  return outputPath;
46
42
  }
47
43
 
48
- /**
49
- * Read a v5 log-embed XLSX file and extract payload.
50
- * Uses fast regex-based XML scanning instead of full DOM parsing for speed.
51
- * @param {string} xlsxPath - Path to XLSX file
52
- * @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata }
53
- */
54
- async function readXlsxV5(xlsxPath) {
55
- if (!fs.existsSync(xlsxPath)) {
56
- throw new Error(`XLSX file not found: ${xlsxPath}`);
57
- }
58
-
59
- // Read file once and reuse the zip instance
60
- const fileBuffer = fs.readFileSync(xlsxPath);
61
- const zip = new AdmZip(fileBuffer);
62
-
63
- // Parse shared strings — handles both normal and namespace-prefixed XML (ns0:si, ns0:t)
64
- let sharedStrings = null;
65
- const ssEntry = zip.getEntry('xl/sharedStrings.xml');
66
- if (ssEntry) {
67
- sharedStrings = [];
68
- const ssXml = ssEntry.getData().toString('utf8');
69
- // Match <si><t>...</t></si> or <ns0:si><ns0:t>...</ns0:t></ns0:si> (any nsN prefix)
70
- const siRegex = /<(?:\w+:)?si><(?:\w+:)?t[^>]*>([^<]*)<\/(?:\w+:)?t><\/(?:\w+:)?si>/g;
71
- let match;
72
- while ((match = siRegex.exec(ssXml)) !== null) {
73
- sharedStrings.push(decodeXmlEntities(match[1]));
74
- }
75
- }
76
-
77
- // Extract sheet1.xml raw string
78
- const sheetEntry = zip.getEntry('xl/worksheets/sheet1.xml');
79
- if (!sheetEntry) {
80
- throw new Error('Sheet not found in XLSX file.');
81
- }
82
- const sheetXml = sheetEntry.getData().toString('utf8');
83
-
84
- // Fast row extraction using regex — handles both normal and namespace-prefixed XML
85
- const allRows = [];
86
- const rowRegex = /<(?:\w+:)?row [^>]*>(.*?)<\/(?:\w+:)?row>/gs;
87
- const cellRegex = /<(?:\w+:)?c r="([A-Z]+)\d+"(?: t="([^"]*)")?[^>]*>(?:<(?:\w+:)?v>([^<]*)<\/(?:\w+:)?v>|<(?:\w+:)?is><(?:\w+:)?t[^>]*>([^<]*)<\/(?:\w+:)?t><\/(?:\w+:)?is>)?<\/(?:\w+:)?c>/g;
88
-
89
- let rowMatch;
90
- while ((rowMatch = rowRegex.exec(sheetXml)) !== null) {
91
- const rowXml = rowMatch[1];
92
- const rowValues = [];
93
-
94
- let cellMatch;
95
- cellRegex.lastIndex = 0;
96
- while ((cellMatch = cellRegex.exec(rowXml)) !== null) {
97
- const colLetter = cellMatch[1];
98
- const cellType = cellMatch[2] || '';
99
- const vValue = cellMatch[3];
100
- const inlineValue = cellMatch[4];
101
-
102
- const colIdx = colLetterToIndex(colLetter);
103
-
104
- let value;
105
- if (cellType === 's' && vValue !== undefined && sharedStrings) {
106
- const ssIndex = parseInt(vValue, 10);
107
- value = ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : '';
108
- } else if (inlineValue !== undefined) {
109
- value = decodeXmlEntities(inlineValue);
110
- } else if (vValue !== undefined) {
111
- value = decodeXmlEntities(vValue);
112
- } else {
113
- value = '';
114
- }
115
-
116
- rowValues[colIdx] = value;
117
- }
118
-
119
- allRows.push(rowValues);
120
- }
121
-
122
- // Skip the first row (column headers)
123
- if (allRows.length < 2) {
124
- throw new Error('Not enough rows in XLSX file for v5 format.');
125
- }
126
-
127
- const dataRows = allRows.slice(1); // Skip header row
128
-
129
- // Decode log lines
130
- return decodeLogLines(dataRows);
131
- }
132
-
133
- /**
134
- * Decode XML entities in a string value
135
- */
136
- function decodeXmlEntities(str) {
137
- if (!str || !str.includes('&')) return str;
138
- return str
139
- .replace(/&amp;/g, '&')
140
- .replace(/&lt;/g, '<')
141
- .replace(/&gt;/g, '>')
142
- .replace(/&apos;/g, "'")
143
- .replace(/&quot;/g, '"');
144
- }
145
-
146
- /**
147
- * Convert column letter to 0-based index (A=0, B=1, ..., Z=25, AA=26)
148
- */
149
- function colLetterToIndex(letters) {
150
- let index = 0;
151
- for (let i = 0; i < letters.length; i++) {
152
- index = index * 26 + (letters.charCodeAt(i) - 64);
153
- }
154
- return index - 1; // 0-based
155
- }
156
-
157
- /**
158
- * Detect whether an XLSX file is v5 (log-embed) or v3/v4 (hidden sheet) format.
159
- * @param {string} xlsxPath - Path to XLSX file
160
- * @returns {string} 'v5' or 'legacy'
161
- */
162
- function detectXlsxVersion(xlsxPath) {
163
- const zip = new AdmZip(xlsxPath);
164
-
165
- // v5 files have only sheet1.xml, v3/v4 have sheet2.xml (hidden data sheet)
166
- const sheet2 = zip.getEntry('xl/worksheets/sheet2.xml');
167
- if (sheet2) {
168
- return 'legacy';
169
- }
170
-
171
- // Double-check: look at sheet name in workbook.xml
172
- const wbEntry = zip.getEntry('xl/workbook.xml');
173
- if (wbEntry) {
174
- const wbXml = wbEntry.getData().toString('utf8');
175
- if (wbXml.includes('Access Logs') || wbXml.includes('access_log')) {
176
- return 'v5';
177
- }
178
- }
179
-
180
- // Default: try v5 if only one sheet exists
181
- return 'v5';
182
- }
183
-
184
- // ─── v3/v4 Legacy XLSX Creation (removed — no longer passes air gap filters)
185
-
186
- // ─── Unified Reader ─────────────────────────────────────────────────────────
187
-
188
- /**
189
- * Read an XLSX file and extract content. Auto-detects v5 vs v3/v4 format.
190
- * @param {string} xlsxPath - Path to XLSX file
191
- * @returns {Promise<object>} For v5: { payloadBuffer, metadataJson, encryptionMeta, metadata, formatVersion: 'v5' }
192
- * For legacy: { base64Content, encryptionMeta, metadata, formatVersion: 'legacy' }
193
- */
194
- async function readXlsxBase64(xlsxPath) {
195
- if (!fs.existsSync(xlsxPath)) {
196
- throw new Error(`XLSX file not found: ${xlsxPath}`);
197
- }
198
-
199
- const version = detectXlsxVersion(xlsxPath);
200
-
201
- if (version === 'v5') {
202
- const result = await readXlsxV5(xlsxPath);
203
- return {
204
- ...result,
205
- formatVersion: 'v5',
206
- };
207
- }
208
-
209
- const result = await extractFromXml(xlsxPath);
210
- return {
211
- ...result,
212
- formatVersion: 'legacy',
213
- };
214
- }
215
-
216
- // ─── Legacy XML Extraction ──────────────────────────────────────────────────
217
-
218
- async function extractFromXml(xlsxPath) {
219
- let sharedStrings = [];
220
- const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
221
-
222
- if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
223
- const siArray = ensureArray(ssParsed.sst.si);
224
- sharedStrings = siArray.map((si) => extractTextContent(si.t));
225
- }
226
-
227
- const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet2.xml');
228
-
229
- if (!sheetParsed) {
230
- throw new Error('Hidden sheet not found in XLSX file. This may not be a stegdoc-encoded file.');
231
- }
232
-
233
- const cellValues = new Map();
234
- const sheetData = sheetParsed.worksheet?.sheetData;
235
-
236
- if (sheetData && sheetData.row) {
237
- const rows = ensureArray(sheetData.row);
238
-
239
- for (const row of rows) {
240
- if (!row.c) continue;
241
- const cells = ensureArray(row.c);
242
-
243
- for (const cell of cells) {
244
- const cellRef = cell['@_r'];
245
- const cellType = cell['@_t'];
246
- const cellValue = cell.v;
247
-
248
- if (cellRef === undefined) continue;
249
-
250
- if (cellType === 's' && cellValue !== undefined) {
251
- const ssIndex = parseInt(cellValue, 10);
252
- if (ssIndex < sharedStrings.length) {
253
- cellValues.set(cellRef, sharedStrings[ssIndex]);
254
- }
255
- } else if (cellType === 'inlineStr' && cell.is) {
256
- const text = extractTextContent(cell.is.t);
257
- if (text !== undefined) {
258
- cellValues.set(cellRef, text);
259
- }
260
- } else if (cellValue !== undefined) {
261
- cellValues.set(cellRef, String(cellValue));
262
- }
263
- }
264
- }
265
- }
266
-
267
- const encryptionMeta = cellValues.get('A1') || '';
268
- const metadata = cellValues.get('B1');
269
- const chunkCountStr = cellValues.get('C1');
270
-
271
- if (!metadata) {
272
- throw new Error('No metadata found in XLSX file.');
273
- }
274
-
275
- const chunkCount = parseInt(chunkCountStr, 10);
276
- if (isNaN(chunkCount) || chunkCount <= 0) {
277
- throw new Error('Invalid chunk count in XLSX file.');
278
- }
279
-
280
- const chunks = [];
281
- for (let i = 0; i < chunkCount; i++) {
282
- const row = Math.floor(i / 26) + 2;
283
- const col = (i % 26) + 1;
284
- const cellRef = `${columnToLetter(col)}${row}`;
285
- const chunk = cellValues.get(cellRef);
286
- if (chunk) {
287
- chunks.push(chunk);
288
- }
289
- }
290
-
291
- return {
292
- base64Content: chunks.join(''),
293
- encryptionMeta,
294
- metadata,
295
- };
296
- }
297
-
298
- // ─── Helpers ────────────────────────────────────────────────────────────────
299
-
300
- function columnToLetter(col) {
301
- let letter = '';
302
- while (col > 0) {
303
- const mod = (col - 1) % 26;
304
- letter = String.fromCharCode(65 + mod) + letter;
305
- col = Math.floor((col - 1) / 26);
306
- }
307
- return letter;
308
- }
309
-
310
44
  module.exports = {
311
- // v5
312
45
  createXlsxPartV5,
313
- readXlsxV5,
314
- detectXlsxVersion,
315
- // Unified reader (auto-detects v5 vs legacy)
316
- readXlsxBase64,
317
46
  };
@@ -1,160 +0,0 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { parseMetadata } = require('./metadata');
4
- const { parseFilename } = require('./utils');
5
-
6
- /**
7
- * Extract content and metadata based on format
8
- * @param {object} readResult - Result from readFile
9
- * @param {string} format - File format ('xlsx' or 'docx')
10
- * @returns {object} { encryptedContent, encryptionMeta, metadata }
11
- */
12
- function extractContent(readResult, format) {
13
- // v5 log-embed format returns metadata already parsed
14
- if (readResult.formatVersion === 'v5') {
15
- return {
16
- encryptedContent: null, // v5 uses payloadBuffer instead
17
- encryptionMeta: readResult.encryptionMeta,
18
- metadata: readResult.metadata,
19
- payloadBuffer: readResult.payloadBuffer,
20
- };
21
- }
22
-
23
- if (format === 'xlsx') {
24
- return {
25
- encryptedContent: readResult.base64Content,
26
- encryptionMeta: readResult.encryptionMeta,
27
- metadata: parseMetadata(readResult.metadata),
28
- };
29
- } else {
30
- // DOCX: encryption meta is embedded in content with ||| separator
31
- const { base64Content, metadata } = readResult;
32
-
33
- // Check if this is a v2+ encrypted file
34
- if (base64Content.includes('|||')) {
35
- const [encryptionMeta, encryptedContent] = base64Content.split('|||');
36
- return {
37
- encryptedContent,
38
- encryptionMeta,
39
- metadata,
40
- };
41
- }
42
-
43
- // Legacy unencrypted DOCX
44
- return {
45
- encryptedContent: base64Content,
46
- encryptionMeta: null,
47
- metadata,
48
- };
49
- }
50
- }
51
-
52
- /**
53
- * Find all parts of a multi-part file in a directory
54
- * @param {string} dirPath - Directory to search
55
- * @param {string} hash - Original hash from metadata
56
- * @param {string} format - File format ('xlsx' or 'docx')
57
- * @param {number} [expectedParts] - Expected total parts (optional, for validation)
58
- * @returns {Array<{path: string, partNumber: number, filename: string}>} Array of parts sorted by part number
59
- */
60
- function findMultiPartFiles(dirPath, hash, format, expectedParts = null) {
61
- const files = fs.readdirSync(dirPath);
62
- const parts = [];
63
- const ext = format === 'docx' ? '.docx' : '.xlsx';
64
-
65
- // Also support legacy hex filenames for backward compatibility
66
- const legacyBaseHash = hash.length >= 16 ? hash.slice(0, 16) : hash;
67
-
68
- for (const file of files) {
69
- if (!file.toLowerCase().endsWith(ext)) continue;
70
-
71
- const parsed = parseFilename(file);
72
- if (!parsed || parsed.partNumber === null) continue;
73
-
74
- // Match by new realistic filename pattern
75
- // Check if file matches the expected pattern (same reportId from hash)
76
- if (parsed.reportId) {
77
- // New realistic format - match by reportId (last 4 chars of hash)
78
- // This is deterministic and doesn't depend on current date
79
- const expectedReportId = hash.slice(-4).toUpperCase();
80
- if (parsed.reportId === expectedReportId) {
81
- parts.push({
82
- path: path.join(dirPath, file),
83
- partNumber: parsed.partNumber,
84
- filename: file,
85
- dateStr: parsed.dateStr,
86
- timeStr: parsed.timeStr,
87
- });
88
- }
89
- } else if (parsed.baseHash === legacyBaseHash) {
90
- // Legacy hex format - match by base hash
91
- parts.push({
92
- path: path.join(dirPath, file),
93
- partNumber: parsed.partNumber,
94
- filename: file,
95
- });
96
- }
97
- }
98
-
99
- // Sort by part number
100
- parts.sort((a, b) => a.partNumber - b.partNumber);
101
-
102
- // For realistic filenames, ensure all parts have the same date/time pattern
103
- // This handles edge cases where multiple file sets might share the same reportId
104
- if (parts.length > 0 && parts[0].dateStr) {
105
- const refDateStr = parts[0].dateStr;
106
- const refTimeStr = parts[0].timeStr;
107
- const filteredParts = parts.filter(
108
- (p) => p.dateStr === refDateStr && p.timeStr === refTimeStr
109
- );
110
- // If filtering removed some parts, use the filtered set
111
- if (filteredParts.length !== parts.length) {
112
- parts.length = 0;
113
- parts.push(...filteredParts);
114
- }
115
- }
116
-
117
- // Validate sequential parts if expectedParts is provided
118
- if (expectedParts !== null && parts.length === expectedParts) {
119
- for (let i = 0; i < expectedParts; i++) {
120
- if (parts[i].partNumber !== i + 1) {
121
- throw new Error(`Missing part ${i + 1}. Parts must be sequential.`);
122
- }
123
- }
124
- }
125
-
126
- return parts;
127
- }
128
-
129
- /**
130
- * Check if a directory is writable
131
- * @param {string} dirPath - Directory path to check
132
- * @returns {boolean} True if writable
133
- */
134
- function isDirectoryWritable(dirPath) {
135
- try {
136
- if (!fs.existsSync(dirPath)) {
137
- fs.mkdirSync(dirPath, { recursive: true });
138
- }
139
- fs.accessSync(dirPath, fs.constants.W_OK);
140
- return true;
141
- } catch {
142
- return false;
143
- }
144
- }
145
-
146
- /**
147
- * Merge base64 chunks back into a single string
148
- * @param {Array<string>} chunks - Array of base64 chunks
149
- * @returns {string} Merged base64 string
150
- */
151
- function mergeBase64Chunks(chunks) {
152
- return chunks.join('');
153
- }
154
-
155
- module.exports = {
156
- extractContent,
157
- findMultiPartFiles,
158
- isDirectoryWritable,
159
- mergeBase64Chunks,
160
- };