stegdoc 1.0.1 → 4.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/package.json +6 -7
- package/src/commands/decode.js +233 -91
- package/src/commands/encode.js +302 -199
- package/src/commands/info.js +10 -9
- package/src/commands/verify.js +65 -30
- package/src/index.js +2 -2
- package/src/lib/compression.js +18 -0
- package/src/lib/crypto.js +54 -0
- package/src/lib/docx-handler.js +1 -1
- package/src/lib/metadata.js +13 -2
- package/src/lib/streams.js +197 -0
- package/src/lib/utils.js +2 -2
- package/src/lib/xlsx-handler.js +133 -76
- package/bootstrap.js +0 -33
package/src/commands/encode.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
+
const { pipeline } = require('stream/promises');
|
|
3
4
|
const chalk = require('chalk');
|
|
4
5
|
const ora = require('ora');
|
|
5
6
|
const AdmZip = require('adm-zip');
|
|
6
7
|
const { createDocxWithBase64 } = require('../lib/docx-handler');
|
|
7
|
-
const {
|
|
8
|
+
const { createXlsxPartStreaming } = require('../lib/xlsx-handler');
|
|
8
9
|
const { createMetadata, serializeMetadata } = require('../lib/metadata');
|
|
9
|
-
const
|
|
10
|
-
const {
|
|
11
|
-
const {
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
|
|
12
|
+
const { packEncryptionMeta, generateSalt, createEncryptStream } = require('../lib/crypto');
|
|
13
|
+
const { isCompressedMime, createCompressStream } = require('../lib/compression');
|
|
12
14
|
const { resetTimeWindow } = require('../lib/decoy-generator');
|
|
13
15
|
const { shouldRunInteractive, promptEncodeOptions } = require('../lib/interactive');
|
|
16
|
+
const { Base64EncodeTransform, ChunkCollector, BinaryChunkCollector } = require('../lib/streams');
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Zip a folder into a buffer
|
|
@@ -24,16 +27,43 @@ function zipFolder(folderPath) {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
|
-
*
|
|
30
|
+
* Detect file type from the first 4KB without reading the entire file
|
|
31
|
+
* @param {string} filePath - Path to file
|
|
32
|
+
* @returns {Promise<object|null>} File type result or null
|
|
33
|
+
*/
|
|
34
|
+
async function detectFileType(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
37
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
38
|
+
const buf = Buffer.alloc(4100);
|
|
39
|
+
await fd.read(buf, 0, 4100, 0);
|
|
40
|
+
await fd.close();
|
|
41
|
+
return await fileTypeFromBuffer(buf);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute SHA-256 hash of a file using streaming (no full file in memory)
|
|
49
|
+
* @param {string} filePath - Path to file
|
|
50
|
+
* @returns {Promise<string>} Hex SHA-256 hash
|
|
51
|
+
*/
|
|
52
|
+
async function computeFileHash(filePath) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const hash = crypto.createHash('sha256');
|
|
55
|
+
const stream = fs.createReadStream(filePath);
|
|
56
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
57
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
58
|
+
stream.on('error', reject);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encode a file to XLSX/DOCX format with optional AES encryption and compression.
|
|
64
|
+
* Uses streaming pipeline for XLSX to support multi-GB files.
|
|
28
65
|
* @param {string} inputFile - Path to input file
|
|
29
66
|
* @param {object} options - Command options
|
|
30
|
-
* @param {string} options.outputDir - Output directory
|
|
31
|
-
* @param {string} options.chunkSize - Chunk size (e.g., "5MB")
|
|
32
|
-
* @param {string} options.format - Output format ('xlsx' or 'docx')
|
|
33
|
-
* @param {string} options.password - Encryption password (optional)
|
|
34
|
-
* @param {boolean} options.force - Overwrite existing files without asking
|
|
35
|
-
* @param {boolean} options.quiet - Minimal output
|
|
36
|
-
* @param {boolean} options.yes - Skip interactive prompts, use defaults
|
|
37
67
|
*/
|
|
38
68
|
async function encodeCommand(inputFile, options) {
|
|
39
69
|
// Check if we should run interactive mode
|
|
@@ -69,261 +99,227 @@ async function encodeCommand(inputFile, options) {
|
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
const useEncryption = !!options.password;
|
|
72
|
-
|
|
73
|
-
// Store chunk input for later processing (after we know content size)
|
|
74
102
|
const chunkInput = (options.chunkSize || '').toString().trim();
|
|
75
103
|
|
|
76
|
-
let
|
|
104
|
+
let streamSource;
|
|
77
105
|
let filename;
|
|
78
106
|
let extension;
|
|
79
|
-
let
|
|
107
|
+
let fileSize;
|
|
108
|
+
let tempZipPath = null;
|
|
80
109
|
|
|
81
110
|
if (isDirectory) {
|
|
82
|
-
// Zip the folder
|
|
111
|
+
// Zip the folder to a temp file, then stream from it
|
|
83
112
|
spinner.text = 'Zipping folder...';
|
|
84
113
|
const folderName = path.basename(inputFile);
|
|
85
|
-
|
|
114
|
+
const zipBuffer = zipFolder(inputFile);
|
|
86
115
|
filename = `${folderName}.zip`;
|
|
87
116
|
extension = '.zip';
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
fileSize = zipBuffer.length;
|
|
118
|
+
|
|
119
|
+
// Write zip to temp file for streaming
|
|
120
|
+
tempZipPath = path.join(require('os').tmpdir(), `stegdoc_${Date.now()}.zip`);
|
|
121
|
+
fs.writeFileSync(tempZipPath, zipBuffer);
|
|
122
|
+
streamSource = tempZipPath;
|
|
123
|
+
|
|
124
|
+
spinner.succeed && spinner.succeed(`Folder zipped: ${folderName}/ → ${formatBytes(fileSize)}`);
|
|
90
125
|
} else {
|
|
91
|
-
|
|
92
|
-
spinner.text = 'Reading file...';
|
|
93
|
-
fileBuffer = fs.readFileSync(inputFile);
|
|
126
|
+
streamSource = inputFile;
|
|
94
127
|
filename = path.basename(inputFile);
|
|
95
128
|
extension = path.extname(inputFile);
|
|
96
|
-
|
|
97
|
-
spinner.succeed && spinner.succeed(`File
|
|
129
|
+
fileSize = fs.statSync(inputFile).size;
|
|
130
|
+
spinner.succeed && spinner.succeed(`File detected: ${filename} (${formatBytes(fileSize)})`);
|
|
98
131
|
}
|
|
99
132
|
|
|
100
|
-
//
|
|
101
|
-
const contentHash = generateContentHash(fileBuffer);
|
|
102
|
-
|
|
103
|
-
// Detect if file is already compressed
|
|
133
|
+
// Detect file type for compression decision (read only first 4KB)
|
|
104
134
|
spinner.text = 'Checking file type...';
|
|
105
|
-
let fileType = null;
|
|
106
135
|
let useCompression = true;
|
|
136
|
+
const fileType = await detectFileType(streamSource);
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
fileType = await fileTypeFromBuffer(fileBuffer);
|
|
112
|
-
|
|
113
|
-
if (fileType && isCompressedMime(fileType.mime)) {
|
|
114
|
-
useCompression = false;
|
|
115
|
-
spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// If file-type detection fails, still try compression
|
|
138
|
+
if (fileType && isCompressedMime(fileType.mime)) {
|
|
139
|
+
useCompression = false;
|
|
140
|
+
spinner.info && spinner.info(`Skipping compression (${fileType.ext} is already compressed)`);
|
|
119
141
|
}
|
|
120
142
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const compressedBuffer = await compress(fileBuffer);
|
|
126
|
-
|
|
127
|
-
// Only use compression if it actually reduces size
|
|
128
|
-
if (compressedBuffer.length < fileBuffer.length) {
|
|
129
|
-
const savedPercent = ((1 - compressedBuffer.length / fileBuffer.length) * 100).toFixed(1);
|
|
130
|
-
processedBuffer = compressedBuffer;
|
|
131
|
-
spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)} (${savedPercent}% saved)`);
|
|
132
|
-
} else {
|
|
133
|
-
useCompression = false;
|
|
134
|
-
spinner.info && spinner.info('Compression skipped (no size benefit)');
|
|
143
|
+
// For DOCX format, warn about large files and use legacy in-memory path
|
|
144
|
+
if (format === 'docx') {
|
|
145
|
+
if (fileSize > 200 * 1024 * 1024) {
|
|
146
|
+
spinner.info && spinner.info('Warning: DOCX format is not recommended for files >200MB. Consider using XLSX.');
|
|
135
147
|
}
|
|
148
|
+
// Use legacy in-memory path for DOCX
|
|
149
|
+
await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
|
|
150
|
+
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
151
|
+
return;
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
//
|
|
139
|
-
const base64 = processedBuffer.toString('base64');
|
|
140
|
-
|
|
141
|
-
let contentToStore;
|
|
142
|
-
let encryptionMeta = null;
|
|
143
|
-
|
|
144
|
-
if (useEncryption) {
|
|
145
|
-
spinner.text = 'Encrypting content...';
|
|
146
|
-
|
|
147
|
-
// Encrypt the base64 content
|
|
148
|
-
const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
|
|
149
|
-
encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
|
|
150
|
-
contentToStore = ciphertext;
|
|
151
|
-
|
|
152
|
-
spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
|
|
153
|
-
} else {
|
|
154
|
-
contentToStore = base64;
|
|
155
|
-
spinner.info && spinner.info('No password provided - content will NOT be encrypted');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
spinner.text = 'Preparing output...';
|
|
159
|
-
|
|
160
|
-
// Generate hash for this encoding session
|
|
154
|
+
// === XLSX Streaming Pipeline ===
|
|
161
155
|
const hash = generateHash();
|
|
162
|
-
|
|
163
|
-
// Determine output directory
|
|
164
156
|
const outputDir = options.outputDir || process.cwd();
|
|
165
157
|
|
|
166
|
-
//
|
|
167
|
-
const contentSize = contentToStore.length;
|
|
168
|
-
const defaultChunkSize = 5 * 1024 * 1024; // 5MB default
|
|
169
|
-
let chunkSizeBytes;
|
|
158
|
+
// Parse chunk size
|
|
170
159
|
const chunkInputLower = chunkInput.toLowerCase();
|
|
160
|
+
let chunkSizeBytes;
|
|
171
161
|
|
|
172
162
|
if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
|
|
173
|
-
chunkSizeBytes = Infinity;
|
|
163
|
+
chunkSizeBytes = Infinity;
|
|
174
164
|
} else if (/^\d+\s*parts?$/i.test(chunkInput)) {
|
|
175
|
-
// "X parts" format - divide content evenly
|
|
176
165
|
const numParts = parseInt(chunkInput, 10);
|
|
177
166
|
if (numParts < 1) {
|
|
178
167
|
throw new Error('Number of parts must be at least 1');
|
|
179
168
|
}
|
|
180
|
-
|
|
181
|
-
|
|
169
|
+
// Estimate output size for part calculation
|
|
170
|
+
const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
|
|
171
|
+
chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
|
|
172
|
+
spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
|
|
182
173
|
} else if (chunkInput) {
|
|
183
174
|
chunkSizeBytes = parseSizeToBytes(chunkInput);
|
|
184
175
|
} else {
|
|
185
|
-
chunkSizeBytes =
|
|
176
|
+
chunkSizeBytes = 5 * 1024 * 1024; // 5MB default
|
|
186
177
|
}
|
|
187
178
|
|
|
188
|
-
//
|
|
189
|
-
|
|
179
|
+
// Pre-compute content hash (fast streaming SHA-256 pass)
|
|
180
|
+
spinner.text = 'Computing file hash...';
|
|
181
|
+
const contentHash = await computeFileHash(streamSource);
|
|
190
182
|
|
|
191
|
-
//
|
|
192
|
-
const
|
|
193
|
-
if (fs.existsSync(filePath) && !options.force) {
|
|
194
|
-
throw new Error(`File already exists: ${filePath}. Use --force to overwrite.`);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
if (needsSplit) {
|
|
199
|
-
// Split into chunks
|
|
200
|
-
const chunks = [];
|
|
201
|
-
let offset = 0;
|
|
202
|
-
while (offset < contentToStore.length) {
|
|
203
|
-
chunks.push(contentToStore.slice(offset, offset + chunkSizeBytes));
|
|
204
|
-
offset += chunkSizeBytes;
|
|
205
|
-
}
|
|
206
|
-
const totalParts = chunks.length;
|
|
183
|
+
// Generate session salt for encryption (shared across all parts)
|
|
184
|
+
const sessionSalt = useEncryption ? generateSalt() : null;
|
|
207
185
|
|
|
208
|
-
|
|
186
|
+
spinner.text = useCompression ? 'Compressing and encoding...' : 'Encoding...';
|
|
209
187
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
188
|
+
// We don't know totalParts upfront (depends on compression ratio).
|
|
189
|
+
// We write each part with partNumber set, and totalParts as null.
|
|
190
|
+
// Decode discovers all parts by filename pattern matching (already supported).
|
|
191
|
+
const partFiles = [];
|
|
192
|
+
|
|
193
|
+
if (useEncryption) {
|
|
194
|
+
// Encrypted pipeline: compress → collect binary chunks → encrypt per-part → base64 → xlsx
|
|
195
|
+
// Binary chunk size accounts for base64 expansion (~4/3x)
|
|
196
|
+
const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
|
|
197
|
+
|
|
198
|
+
const onBinaryChunkReady = async (binaryBuffer, index) => {
|
|
199
|
+
const partNumber = index + 1;
|
|
200
|
+
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
201
|
+
|
|
202
|
+
// Encrypt this chunk independently
|
|
203
|
+
const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
|
|
204
|
+
const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
|
|
205
|
+
const authTag = getAuthTag();
|
|
206
|
+
const base64Chunk = encrypted.toString('base64');
|
|
214
207
|
|
|
215
208
|
const metadata = createMetadata({
|
|
216
209
|
originalFilename: filename,
|
|
217
210
|
originalExtension: extension,
|
|
218
211
|
hash,
|
|
219
212
|
partNumber,
|
|
220
|
-
totalParts,
|
|
221
|
-
originalSize:
|
|
213
|
+
totalParts: null,
|
|
214
|
+
originalSize: fileSize,
|
|
222
215
|
format,
|
|
223
|
-
encrypted:
|
|
216
|
+
encrypted: true,
|
|
224
217
|
compressed: useCompression,
|
|
225
218
|
contentHash,
|
|
226
219
|
});
|
|
227
220
|
|
|
228
|
-
const
|
|
221
|
+
const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
|
|
222
|
+
const outputFilename = generateFilename(hash, partNumber, null, format);
|
|
229
223
|
const outputPath = path.join(outputDir, outputFilename);
|
|
230
224
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (format === 'xlsx') {
|
|
234
|
-
await createXlsxWithBase64({
|
|
235
|
-
base64Content: chunks[i],
|
|
236
|
-
encryptionMeta: encryptionMeta || '',
|
|
237
|
-
metadata: serializeMetadata(metadata),
|
|
238
|
-
outputPath,
|
|
239
|
-
});
|
|
240
|
-
} else {
|
|
241
|
-
// For DOCX, include encryption meta in the content if encrypted
|
|
242
|
-
const docxContent = useEncryption
|
|
243
|
-
? `${encryptionMeta}|||${chunks[i]}`
|
|
244
|
-
: chunks[i];
|
|
245
|
-
await createDocxWithBase64({
|
|
246
|
-
base64Content: docxContent,
|
|
247
|
-
metadata,
|
|
248
|
-
outputPath,
|
|
249
|
-
});
|
|
225
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
226
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
250
227
|
}
|
|
251
228
|
|
|
229
|
+
await createXlsxPartStreaming({
|
|
230
|
+
base64Content: base64Chunk,
|
|
231
|
+
encryptionMeta,
|
|
232
|
+
metadataJson: serializeMetadata(metadata),
|
|
233
|
+
outputPath,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
partFiles.push(outputPath);
|
|
252
237
|
createdFiles.push(outputPath);
|
|
253
|
-
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(
|
|
254
|
-
}
|
|
238
|
+
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
|
|
239
|
+
};
|
|
255
240
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
263
|
-
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
264
|
-
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
265
|
-
if (useEncryption) {
|
|
266
|
-
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
267
|
-
}
|
|
241
|
+
const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
|
|
242
|
+
|
|
243
|
+
// Build pipeline
|
|
244
|
+
const streams = [fs.createReadStream(streamSource)];
|
|
245
|
+
if (useCompression) {
|
|
246
|
+
streams.push(createCompressStream());
|
|
268
247
|
}
|
|
248
|
+
streams.push(collector);
|
|
249
|
+
|
|
250
|
+
await pipeline(...streams);
|
|
269
251
|
} else {
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const outputFilename = generateFilename(hash, null, null, format);
|
|
287
|
-
const outputPath = path.join(outputDir, outputFilename);
|
|
288
|
-
|
|
289
|
-
checkOverwrite(outputPath);
|
|
290
|
-
|
|
291
|
-
if (format === 'xlsx') {
|
|
292
|
-
await createXlsxWithBase64({
|
|
293
|
-
base64Content: contentToStore,
|
|
294
|
-
encryptionMeta: encryptionMeta || '',
|
|
295
|
-
metadata: serializeMetadata(metadata),
|
|
296
|
-
outputPath,
|
|
252
|
+
// Unencrypted pipeline: compress → base64 → collect string chunks → xlsx
|
|
253
|
+
const onChunkReady = async (base64Chunk, index) => {
|
|
254
|
+
const partNumber = index + 1;
|
|
255
|
+
const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
|
|
256
|
+
|
|
257
|
+
const metadata = createMetadata({
|
|
258
|
+
originalFilename: filename,
|
|
259
|
+
originalExtension: extension,
|
|
260
|
+
hash,
|
|
261
|
+
partNumber,
|
|
262
|
+
totalParts: null,
|
|
263
|
+
originalSize: fileSize,
|
|
264
|
+
format,
|
|
265
|
+
encrypted: false,
|
|
266
|
+
compressed: useCompression,
|
|
267
|
+
contentHash,
|
|
297
268
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
269
|
+
|
|
270
|
+
const outputFilename = generateFilename(hash, partNumber, null, format);
|
|
271
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
272
|
+
|
|
273
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
274
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await createXlsxPartStreaming({
|
|
278
|
+
base64Content: base64Chunk,
|
|
279
|
+
encryptionMeta: '',
|
|
280
|
+
metadataJson: serializeMetadata(metadata),
|
|
306
281
|
outputPath,
|
|
307
282
|
});
|
|
283
|
+
|
|
284
|
+
partFiles.push(outputPath);
|
|
285
|
+
createdFiles.push(outputPath);
|
|
286
|
+
partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
|
|
290
|
+
|
|
291
|
+
// Build pipeline
|
|
292
|
+
const streams = [fs.createReadStream(streamSource)];
|
|
293
|
+
if (useCompression) {
|
|
294
|
+
streams.push(createCompressStream());
|
|
308
295
|
}
|
|
296
|
+
streams.push(new Base64EncodeTransform());
|
|
297
|
+
streams.push(collector);
|
|
309
298
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
299
|
+
await pipeline(...streams);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const totalParts = partFiles.length;
|
|
303
|
+
|
|
304
|
+
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
305
|
+
|
|
306
|
+
if (!quiet) {
|
|
307
|
+
console.log();
|
|
308
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
309
|
+
console.log(chalk.cyan(` Format: ${format.toUpperCase()}`));
|
|
310
|
+
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
311
|
+
if (totalParts > 1) {
|
|
312
|
+
console.log(chalk.cyan(` Parts: ${totalParts}`));
|
|
313
|
+
}
|
|
314
|
+
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
315
|
+
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
316
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
317
|
+
if (useEncryption) {
|
|
318
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
325
319
|
}
|
|
326
320
|
}
|
|
321
|
+
|
|
322
|
+
if (tempZipPath) cleanupTemp(tempZipPath);
|
|
327
323
|
} catch (error) {
|
|
328
324
|
spinner.fail && spinner.fail('Encoding failed');
|
|
329
325
|
|
|
@@ -343,4 +339,111 @@ async function encodeCommand(inputFile, options) {
|
|
|
343
339
|
}
|
|
344
340
|
}
|
|
345
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Legacy in-memory encode path for DOCX format
|
|
344
|
+
*/
|
|
345
|
+
async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
|
|
346
|
+
const { compress } = require('../lib/compression');
|
|
347
|
+
const { encrypt, packEncryptionMeta: packMeta } = require('../lib/crypto');
|
|
348
|
+
const { generateContentHash } = require('../lib/utils');
|
|
349
|
+
const { createDocxWithBase64 } = require('../lib/docx-handler');
|
|
350
|
+
|
|
351
|
+
const fileBuffer = fs.readFileSync(inputPath);
|
|
352
|
+
const contentHash = generateContentHash(fileBuffer);
|
|
353
|
+
|
|
354
|
+
// Compress if beneficial
|
|
355
|
+
let processedBuffer = fileBuffer;
|
|
356
|
+
if (useCompression) {
|
|
357
|
+
spinner.text = 'Compressing...';
|
|
358
|
+
const compressedBuffer = await compress(fileBuffer);
|
|
359
|
+
if (compressedBuffer.length < fileBuffer.length) {
|
|
360
|
+
processedBuffer = compressedBuffer;
|
|
361
|
+
spinner.succeed && spinner.succeed(`Compressed: ${formatBytes(fileBuffer.length)} → ${formatBytes(compressedBuffer.length)}`);
|
|
362
|
+
} else {
|
|
363
|
+
useCompression = false;
|
|
364
|
+
spinner.info && spinner.info('Compression skipped (no size benefit)');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Convert to base64
|
|
369
|
+
const base64 = processedBuffer.toString('base64');
|
|
370
|
+
|
|
371
|
+
let contentToStore;
|
|
372
|
+
let encryptionMeta = null;
|
|
373
|
+
|
|
374
|
+
if (useEncryption) {
|
|
375
|
+
spinner.text = 'Encrypting content...';
|
|
376
|
+
const { ciphertext, iv, salt, authTag } = encrypt(base64, options.password);
|
|
377
|
+
encryptionMeta = packMeta({ iv, salt, authTag });
|
|
378
|
+
contentToStore = ciphertext;
|
|
379
|
+
spinner.succeed && spinner.succeed('Content encrypted with AES-256-GCM');
|
|
380
|
+
} else {
|
|
381
|
+
contentToStore = base64;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const hash = generateHash();
|
|
385
|
+
const outputDir = options.outputDir || process.cwd();
|
|
386
|
+
const format = 'docx';
|
|
387
|
+
|
|
388
|
+
const metadata = createMetadata({
|
|
389
|
+
originalFilename: filename,
|
|
390
|
+
originalExtension: extension,
|
|
391
|
+
hash,
|
|
392
|
+
partNumber: null,
|
|
393
|
+
totalParts: null,
|
|
394
|
+
originalSize: fileSize,
|
|
395
|
+
format,
|
|
396
|
+
encrypted: useEncryption,
|
|
397
|
+
compressed: useCompression,
|
|
398
|
+
contentHash,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const outputFilename = generateFilename(hash, null, null, format);
|
|
402
|
+
const outputPath = path.join(outputDir, outputFilename);
|
|
403
|
+
|
|
404
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
405
|
+
throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const docxContent = useEncryption
|
|
409
|
+
? `${encryptionMeta}|||${contentToStore}`
|
|
410
|
+
: contentToStore;
|
|
411
|
+
|
|
412
|
+
await createDocxWithBase64({
|
|
413
|
+
base64Content: docxContent,
|
|
414
|
+
metadata,
|
|
415
|
+
outputPath,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
createdFiles.push(outputPath);
|
|
419
|
+
spinner.succeed && spinner.succeed('Encoding complete!');
|
|
420
|
+
|
|
421
|
+
if (!quiet) {
|
|
422
|
+
console.log();
|
|
423
|
+
console.log(chalk.green.bold('✓ File encoded successfully!'));
|
|
424
|
+
console.log(chalk.cyan(` Format: DOCX`));
|
|
425
|
+
console.log(chalk.cyan(` Hash: ${hash}`));
|
|
426
|
+
console.log(chalk.cyan(` Output: ${outputFilename}`));
|
|
427
|
+
console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
|
|
428
|
+
console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes' : 'No'}`));
|
|
429
|
+
console.log(chalk.cyan(` Location: ${outputDir}`));
|
|
430
|
+
if (useEncryption) {
|
|
431
|
+
console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Cleanup temporary file
|
|
438
|
+
*/
|
|
439
|
+
function cleanupTemp(tempPath) {
|
|
440
|
+
try {
|
|
441
|
+
if (fs.existsSync(tempPath)) {
|
|
442
|
+
fs.unlinkSync(tempPath);
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Ignore
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
346
449
|
module.exports = encodeCommand;
|
package/src/commands/info.js
CHANGED
|
@@ -63,20 +63,21 @@ async function infoCommand(inputFile, options) {
|
|
|
63
63
|
}
|
|
64
64
|
console.log();
|
|
65
65
|
|
|
66
|
-
// Multi-part info
|
|
67
|
-
|
|
66
|
+
// Multi-part info — check both totalParts and partNumber (v4 may have null totalParts)
|
|
67
|
+
const hasMultipleParts = isMultiPart(metadata) || metadata.partNumber !== null;
|
|
68
|
+
if (hasMultipleParts) {
|
|
68
69
|
console.log(chalk.bold.white('Multi-part File:'));
|
|
69
|
-
console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${metadata.totalParts}`));
|
|
70
|
-
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
71
|
-
|
|
72
|
-
// Find other parts
|
|
73
70
|
const inputDir = path.dirname(inputFile);
|
|
74
71
|
const allParts = findMultiPartFiles(inputDir, metadata.hash, format);
|
|
72
|
+
const totalParts = metadata.totalParts || allParts.length;
|
|
73
|
+
|
|
74
|
+
console.log(chalk.cyan(` This is part: ${metadata.partNumber} of ${totalParts}`));
|
|
75
|
+
console.log(chalk.cyan(` Hash: ${metadata.hash}`));
|
|
75
76
|
|
|
76
77
|
console.log();
|
|
77
78
|
console.log(chalk.bold.white('Parts found in directory:'));
|
|
78
79
|
|
|
79
|
-
for (let i = 1; i <=
|
|
80
|
+
for (let i = 1; i <= totalParts; i++) {
|
|
80
81
|
const part = allParts.find(p => p.partNumber === i);
|
|
81
82
|
if (part) {
|
|
82
83
|
console.log(chalk.green(` ✓ Part ${i}: ${part.filename}`));
|
|
@@ -85,12 +86,12 @@ async function infoCommand(inputFile, options) {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
if (allParts.length
|
|
89
|
+
if (allParts.length >= totalParts) {
|
|
89
90
|
console.log();
|
|
90
91
|
console.log(chalk.green.bold('All parts found - ready to decode'));
|
|
91
92
|
} else {
|
|
92
93
|
console.log();
|
|
93
|
-
console.log(chalk.yellow.bold(`Missing ${
|
|
94
|
+
console.log(chalk.yellow.bold(`Missing ${totalParts - allParts.length} part(s)`));
|
|
94
95
|
}
|
|
95
96
|
} else {
|
|
96
97
|
console.log(chalk.bold.white('Single File:'));
|