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 +16 -7
- package/package.json +8 -9
- package/src/commands/decode.js +60 -366
- package/src/commands/encode.js +73 -137
- package/src/commands/info.js +24 -102
- package/src/commands/verify.js +20 -186
- package/src/index.js +0 -1
- package/src/lib/compression.js +1 -161
- package/src/lib/crypto.js +0 -107
- package/src/lib/docx-handler.js +2 -278
- package/src/lib/metadata.js +7 -88
- package/src/lib/native.js +3 -3
- package/src/lib/streams.js +0 -32
- package/src/lib/utils.js +0 -47
- package/src/lib/xlsx-handler.js +1 -272
- package/src/lib/file-utils.js +0 -160
- package/src/lib/xml-utils.js +0 -115
package/src/lib/metadata.js
CHANGED
|
@@ -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:
|
|
43
|
+
pipelineOrder: 'brotli-encrypt-logEmbed',
|
|
48
44
|
encodingDate: new Date().toISOString(),
|
|
49
|
-
version:
|
|
45
|
+
version: '5.0.0',
|
|
50
46
|
tool: 'stegdoc',
|
|
47
|
+
stegoMethod: 'log-embed',
|
|
48
|
+
compressionAlgo: compressionAlgo || 'brotli',
|
|
51
49
|
};
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
*/
|
package/src/lib/streams.js
CHANGED
|
@@ -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
|
};
|
package/src/lib/xlsx-handler.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
const
|
|
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(/&/g, '&')
|
|
140
|
-
.replace(/</g, '<')
|
|
141
|
-
.replace(/>/g, '>')
|
|
142
|
-
.replace(/'/g, "'")
|
|
143
|
-
.replace(/"/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
|
};
|
package/src/lib/file-utils.js
DELETED
|
@@ -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
|
-
};
|