quilltap 3.0.0-dev.81 → 3.0.0-dev.84
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/lib/tar-extract.js +7 -218
- package/package.json +3 -2
package/lib/tar-extract.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the Quilltap standalone tarball.
|
|
7
|
-
*
|
|
8
|
-
* No external dependencies required.
|
|
4
|
+
* Tar.gz extractor using the battle-tested `tar` package (npm's own).
|
|
5
|
+
* Streams extraction directly from the .tar.gz file to disk.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
|
-
const
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const zlib = require('zlib');
|
|
8
|
+
const tar = require('tar');
|
|
14
9
|
|
|
15
10
|
/**
|
|
16
11
|
* Extract a .tar.gz file to the given directory.
|
|
@@ -19,217 +14,11 @@ const zlib = require('zlib');
|
|
|
19
14
|
* @returns {Promise<void>}
|
|
20
15
|
*/
|
|
21
16
|
function extractTarGz(tarGzPath, destDir) {
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
readStream.pipe(gunzip);
|
|
28
|
-
|
|
29
|
-
gunzip.on('data', (chunk) => chunks.push(chunk));
|
|
30
|
-
gunzip.on('error', reject);
|
|
31
|
-
readStream.on('error', reject);
|
|
32
|
-
|
|
33
|
-
gunzip.on('end', () => {
|
|
34
|
-
try {
|
|
35
|
-
const tarBuffer = Buffer.concat(chunks);
|
|
36
|
-
extractTarBuffer(tarBuffer, destDir);
|
|
37
|
-
resolve();
|
|
38
|
-
} catch (err) {
|
|
39
|
-
reject(err);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
17
|
+
return tar.x({
|
|
18
|
+
file: tarGzPath,
|
|
19
|
+
cwd: destDir,
|
|
20
|
+
strip: 0,
|
|
42
21
|
});
|
|
43
22
|
}
|
|
44
23
|
|
|
45
|
-
/**
|
|
46
|
-
* Parse and extract files from a raw tar buffer.
|
|
47
|
-
* @param {Buffer} buffer - The uncompressed tar data
|
|
48
|
-
* @param {string} destDir - Directory to extract into
|
|
49
|
-
*/
|
|
50
|
-
function extractTarBuffer(buffer, destDir) {
|
|
51
|
-
let offset = 0;
|
|
52
|
-
|
|
53
|
-
while (offset + 512 <= buffer.length) {
|
|
54
|
-
const header = buffer.subarray(offset, offset + 512);
|
|
55
|
-
|
|
56
|
-
// Check for end-of-archive (two consecutive zero blocks)
|
|
57
|
-
if (isZeroBlock(header)) {
|
|
58
|
-
break;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const parsed = parseHeader(header);
|
|
62
|
-
if (!parsed) {
|
|
63
|
-
// Skip malformed header
|
|
64
|
-
offset += 512;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
offset += 512; // Move past header
|
|
69
|
-
|
|
70
|
-
const { name, size, type, linkName } = parsed;
|
|
71
|
-
|
|
72
|
-
// Security: prevent path traversal
|
|
73
|
-
const safeName = name.replace(/^\.\//, '');
|
|
74
|
-
if (safeName.startsWith('/') || safeName.includes('..')) {
|
|
75
|
-
// Skip dangerous paths
|
|
76
|
-
offset += Math.ceil(size / 512) * 512;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const fullPath = path.join(destDir, safeName);
|
|
81
|
-
|
|
82
|
-
switch (type) {
|
|
83
|
-
case 'directory':
|
|
84
|
-
// If a file exists at this path, remove it so the directory can be created
|
|
85
|
-
if (fs.existsSync(fullPath) && !fs.statSync(fullPath).isDirectory()) {
|
|
86
|
-
fs.unlinkSync(fullPath);
|
|
87
|
-
}
|
|
88
|
-
fs.mkdirSync(fullPath, { recursive: true });
|
|
89
|
-
break;
|
|
90
|
-
|
|
91
|
-
case 'file': {
|
|
92
|
-
// Ensure parent directory exists
|
|
93
|
-
const dir = path.dirname(fullPath);
|
|
94
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
-
|
|
96
|
-
// If a directory exists at the file path, remove it first
|
|
97
|
-
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
98
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const fileData = buffer.subarray(offset, offset + size);
|
|
102
|
-
fs.writeFileSync(fullPath, fileData);
|
|
103
|
-
|
|
104
|
-
// Set executable permission if needed
|
|
105
|
-
if (parsed.mode & 0o111) {
|
|
106
|
-
try {
|
|
107
|
-
fs.chmodSync(fullPath, parsed.mode);
|
|
108
|
-
} catch {
|
|
109
|
-
// chmod may fail on Windows, that's fine
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
case 'symlink': {
|
|
116
|
-
const dir = path.dirname(fullPath);
|
|
117
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
-
try {
|
|
119
|
-
fs.symlinkSync(linkName, fullPath);
|
|
120
|
-
} catch {
|
|
121
|
-
// Symlinks may fail on Windows without privileges
|
|
122
|
-
}
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Skip other types (hard links, etc.)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Advance past file data (padded to 512-byte boundary)
|
|
130
|
-
offset += Math.ceil(size / 512) * 512;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Parse a 512-byte tar header block.
|
|
136
|
-
* @param {Buffer} header
|
|
137
|
-
* @returns {{ name: string, size: number, type: string, mode: number, linkName: string } | null}
|
|
138
|
-
*/
|
|
139
|
-
function parseHeader(header) {
|
|
140
|
-
// Validate checksum
|
|
141
|
-
const storedChecksum = parseOctal(header, 148, 8);
|
|
142
|
-
const computedChecksum = computeChecksum(header);
|
|
143
|
-
if (storedChecksum !== computedChecksum) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const name = parseName(header, 0, 100);
|
|
148
|
-
const mode = parseOctal(header, 100, 8);
|
|
149
|
-
const size = parseOctal(header, 124, 12);
|
|
150
|
-
const typeFlag = String.fromCharCode(header[156]);
|
|
151
|
-
const linkName = parseName(header, 157, 100);
|
|
152
|
-
|
|
153
|
-
// UStar prefix (extends the name field for longer paths)
|
|
154
|
-
const magic = header.subarray(257, 263).toString('ascii');
|
|
155
|
-
let fullName = name;
|
|
156
|
-
if (magic.startsWith('ustar')) {
|
|
157
|
-
const prefix = parseName(header, 345, 155);
|
|
158
|
-
if (prefix) {
|
|
159
|
-
fullName = prefix + '/' + name;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Determine entry type
|
|
164
|
-
let type;
|
|
165
|
-
switch (typeFlag) {
|
|
166
|
-
case '0':
|
|
167
|
-
case '\0':
|
|
168
|
-
case '': // Regular file
|
|
169
|
-
type = 'file';
|
|
170
|
-
break;
|
|
171
|
-
case '2': // Symbolic link
|
|
172
|
-
type = 'symlink';
|
|
173
|
-
break;
|
|
174
|
-
case '5': // Directory
|
|
175
|
-
type = 'directory';
|
|
176
|
-
break;
|
|
177
|
-
default:
|
|
178
|
-
type = 'other';
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// A name ending with '/' is also a directory
|
|
182
|
-
if (fullName.endsWith('/') && type === 'file' && size === 0) {
|
|
183
|
-
type = 'directory';
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return { name: fullName, size, type, mode, linkName };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Read a null-terminated string from a buffer region.
|
|
191
|
-
*/
|
|
192
|
-
function parseName(buffer, offset, length) {
|
|
193
|
-
const slice = buffer.subarray(offset, offset + length);
|
|
194
|
-
const nullIndex = slice.indexOf(0);
|
|
195
|
-
const str = slice.subarray(0, nullIndex >= 0 ? nullIndex : length).toString('utf-8');
|
|
196
|
-
return str;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Parse an octal number from a buffer region.
|
|
201
|
-
*/
|
|
202
|
-
function parseOctal(buffer, offset, length) {
|
|
203
|
-
const slice = buffer.subarray(offset, offset + length);
|
|
204
|
-
const str = slice.toString('ascii').replace(/\0/g, '').trim();
|
|
205
|
-
if (!str) return 0;
|
|
206
|
-
return parseInt(str, 8) || 0;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Compute the checksum of a tar header (treat checksum field as spaces).
|
|
211
|
-
*/
|
|
212
|
-
function computeChecksum(header) {
|
|
213
|
-
let sum = 0;
|
|
214
|
-
for (let i = 0; i < 512; i++) {
|
|
215
|
-
// The checksum field (bytes 148-155) is treated as spaces (0x20)
|
|
216
|
-
if (i >= 148 && i < 156) {
|
|
217
|
-
sum += 0x20;
|
|
218
|
-
} else {
|
|
219
|
-
sum += header[i];
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return sum;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Check if a 512-byte block is all zeros.
|
|
227
|
-
*/
|
|
228
|
-
function isZeroBlock(block) {
|
|
229
|
-
for (let i = 0; i < 512; i++) {
|
|
230
|
-
if (block[i] !== 0) return false;
|
|
231
|
-
}
|
|
232
|
-
return true;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
24
|
module.exports = { extractTarGz };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "3.0.0-dev.
|
|
3
|
+
"version": "3.0.0-dev.84",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"better-sqlite3": "^12.6.2",
|
|
37
|
-
"sharp": "^0.34.5"
|
|
37
|
+
"sharp": "^0.34.5",
|
|
38
|
+
"tar": "^7.4.3"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=18.0.0"
|