quilltap 3.0.0-dev.81 → 3.0.0-dev.83

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.
Files changed (2) hide show
  1. package/lib/tar-extract.js +7 -218
  2. package/package.json +3 -2
@@ -1,16 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Minimal tar.gz extractor using only Node.js built-ins.
5
- * Handles POSIX tar format (ustar) sufficient for extracting
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 fs = require('fs');
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 new Promise((resolve, reject) => {
23
- const readStream = fs.createReadStream(tarGzPath);
24
- const gunzip = zlib.createGunzip();
25
- const chunks = [];
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.81",
3
+ "version": "3.0.0-dev.83",
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"