quilltap 3.0.0-dev.79 → 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 -209
  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,208 +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
- fs.mkdirSync(fullPath, { recursive: true });
85
- break;
86
-
87
- case 'file': {
88
- // Ensure parent directory exists
89
- const dir = path.dirname(fullPath);
90
- fs.mkdirSync(dir, { recursive: true });
91
-
92
- const fileData = buffer.subarray(offset, offset + size);
93
- fs.writeFileSync(fullPath, fileData);
94
-
95
- // Set executable permission if needed
96
- if (parsed.mode & 0o111) {
97
- try {
98
- fs.chmodSync(fullPath, parsed.mode);
99
- } catch {
100
- // chmod may fail on Windows, that's fine
101
- }
102
- }
103
- break;
104
- }
105
-
106
- case 'symlink': {
107
- const dir = path.dirname(fullPath);
108
- fs.mkdirSync(dir, { recursive: true });
109
- try {
110
- fs.symlinkSync(linkName, fullPath);
111
- } catch {
112
- // Symlinks may fail on Windows without privileges
113
- }
114
- break;
115
- }
116
-
117
- // Skip other types (hard links, etc.)
118
- }
119
-
120
- // Advance past file data (padded to 512-byte boundary)
121
- offset += Math.ceil(size / 512) * 512;
122
- }
123
- }
124
-
125
- /**
126
- * Parse a 512-byte tar header block.
127
- * @param {Buffer} header
128
- * @returns {{ name: string, size: number, type: string, mode: number, linkName: string } | null}
129
- */
130
- function parseHeader(header) {
131
- // Validate checksum
132
- const storedChecksum = parseOctal(header, 148, 8);
133
- const computedChecksum = computeChecksum(header);
134
- if (storedChecksum !== computedChecksum) {
135
- return null;
136
- }
137
-
138
- const name = parseName(header, 0, 100);
139
- const mode = parseOctal(header, 100, 8);
140
- const size = parseOctal(header, 124, 12);
141
- const typeFlag = String.fromCharCode(header[156]);
142
- const linkName = parseName(header, 157, 100);
143
-
144
- // UStar prefix (extends the name field for longer paths)
145
- const magic = header.subarray(257, 263).toString('ascii');
146
- let fullName = name;
147
- if (magic.startsWith('ustar')) {
148
- const prefix = parseName(header, 345, 155);
149
- if (prefix) {
150
- fullName = prefix + '/' + name;
151
- }
152
- }
153
-
154
- // Determine entry type
155
- let type;
156
- switch (typeFlag) {
157
- case '0':
158
- case '\0':
159
- case '': // Regular file
160
- type = 'file';
161
- break;
162
- case '2': // Symbolic link
163
- type = 'symlink';
164
- break;
165
- case '5': // Directory
166
- type = 'directory';
167
- break;
168
- default:
169
- type = 'other';
170
- }
171
-
172
- // A name ending with '/' is also a directory
173
- if (fullName.endsWith('/') && type === 'file' && size === 0) {
174
- type = 'directory';
175
- }
176
-
177
- return { name: fullName, size, type, mode, linkName };
178
- }
179
-
180
- /**
181
- * Read a null-terminated string from a buffer region.
182
- */
183
- function parseName(buffer, offset, length) {
184
- const slice = buffer.subarray(offset, offset + length);
185
- const nullIndex = slice.indexOf(0);
186
- const str = slice.subarray(0, nullIndex >= 0 ? nullIndex : length).toString('utf-8');
187
- return str;
188
- }
189
-
190
- /**
191
- * Parse an octal number from a buffer region.
192
- */
193
- function parseOctal(buffer, offset, length) {
194
- const slice = buffer.subarray(offset, offset + length);
195
- const str = slice.toString('ascii').replace(/\0/g, '').trim();
196
- if (!str) return 0;
197
- return parseInt(str, 8) || 0;
198
- }
199
-
200
- /**
201
- * Compute the checksum of a tar header (treat checksum field as spaces).
202
- */
203
- function computeChecksum(header) {
204
- let sum = 0;
205
- for (let i = 0; i < 512; i++) {
206
- // The checksum field (bytes 148-155) is treated as spaces (0x20)
207
- if (i >= 148 && i < 156) {
208
- sum += 0x20;
209
- } else {
210
- sum += header[i];
211
- }
212
- }
213
- return sum;
214
- }
215
-
216
- /**
217
- * Check if a 512-byte block is all zeros.
218
- */
219
- function isZeroBlock(block) {
220
- for (let i = 0; i < 512; i++) {
221
- if (block[i] !== 0) return false;
222
- }
223
- return true;
224
- }
225
-
226
24
  module.exports = { extractTarGz };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "3.0.0-dev.79",
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"