@ryuu-reinzz/ryuu-cli 0.4.3 → 0.5.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/CHANGELOG.md +70 -0
- package/README.md +26 -0
- package/docs/drive.md +484 -0
- package/package.json +4 -1
- package/plugins/google-drive/auth.js +57 -0
- package/plugins/google-drive/backup.js +282 -0
- package/plugins/google-drive/command.js +266 -0
- package/plugins/google-drive/config.js +252 -0
- package/plugins/google-drive/credentials.js +171 -0
- package/plugins/google-drive/drive-api.js +304 -0
- package/plugins/google-drive/index.js +26 -0
- package/plugins/google-drive/manager.js +507 -0
- package/plugins/google-drive/oauth-backend.js +161 -0
- package/plugins/google-drive/secret.js +50 -0
- package/plugins/google-drive/token-client.js +80 -0
- package/plugins/google-drive/tool-helpers.js +46 -0
- package/plugins/google-drive/tools-backup.js +204 -0
- package/plugins/google-drive/tools-files.js +315 -0
- package/plugins/google-drive/tools.js +13 -0
- package/plugins/google-drive/zip-reader.js +217 -0
- package/plugins/google-drive/zip-writer.js +204 -0
- package/plugins/index.js +56 -0
- package/src/agent/agent.js +12 -2
- package/src/cli/app.js +16 -0
- package/src/cli/commands.js +19 -0
- package/src/cli/markdown.js +10 -5
- package/src/cli/renderer.js +38 -5
- package/src/cli/tui.js +80 -9
- package/src/tools/registry.js +13 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import { defineTool } from '../../src/tools/registry.js';
|
|
3
|
+
import { fileLine, formatBytes, resolveLocal } from './tool-helpers.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File discovery and file management tools (search, list, metadata, download,
|
|
7
|
+
* upload, folder, move, rename, copy, delete).
|
|
8
|
+
*
|
|
9
|
+
* Destructive operations are declared destructive AND are re-checked against
|
|
10
|
+
* `confirmDestructive` at execution time, so a model cannot opt itself out of
|
|
11
|
+
* the safety check by sending an extra parameter.
|
|
12
|
+
*/
|
|
13
|
+
export function createFileTools(plugin) {
|
|
14
|
+
return [
|
|
15
|
+
defineTool({
|
|
16
|
+
name: 'drive_search',
|
|
17
|
+
description:
|
|
18
|
+
'Search Google Drive files by name, MIME type, parent folder id, or a raw Drive query string. Returns id, name, MIME type, size and timestamps for each match.',
|
|
19
|
+
risk: 'read',
|
|
20
|
+
schema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
name: { type: 'string', description: 'Substring of the file name (case-insensitive on Drive side).' },
|
|
24
|
+
mimeType: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Exact MIME type, e.g. application/pdf or application/vnd.google-apps.folder.',
|
|
27
|
+
},
|
|
28
|
+
folderId: { type: 'string', description: 'Only search direct children of this folder id.' },
|
|
29
|
+
query: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: "Raw Drive query fragment, e.g. \"fullText contains 'invoice'\".",
|
|
32
|
+
},
|
|
33
|
+
pageSize: { type: 'integer', minimum: 1, maximum: 100, description: 'Maximum results (default 25).' },
|
|
34
|
+
pageToken: { type: 'string', description: 'Token from a previous call for the next page.' },
|
|
35
|
+
orderBy: { type: 'string', description: 'Drive orderBy clause (default "modifiedTime desc").' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
async handler(args) {
|
|
39
|
+
const drive = await plugin.requireClient();
|
|
40
|
+
const result = await drive.search({
|
|
41
|
+
name: args.name || null,
|
|
42
|
+
mimeType: args.mimeType || null,
|
|
43
|
+
folderId: args.folderId || null,
|
|
44
|
+
query: args.query || null,
|
|
45
|
+
pageSize: args.pageSize ?? 25,
|
|
46
|
+
pageToken: args.pageToken || null,
|
|
47
|
+
orderBy: args.orderBy || 'modifiedTime desc',
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
count: result.files.length,
|
|
52
|
+
files: result.files.map((file) => ({ ...file, sizeLabel: formatBytes(file.size) })),
|
|
53
|
+
nextPageToken: result.nextPageToken,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
defineTool({
|
|
59
|
+
name: 'drive_list',
|
|
60
|
+
description: 'List the immediate contents of a Google Drive folder (use "root" or omit the id for My Drive).',
|
|
61
|
+
risk: 'read',
|
|
62
|
+
schema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
folderId: { type: 'string', description: 'Folder id to list; omit for My Drive root.' },
|
|
66
|
+
pageSize: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum entries (default 50).' },
|
|
67
|
+
pageToken: { type: 'string', description: 'Token from a previous call for the next page.' },
|
|
68
|
+
orderBy: { type: 'string', description: 'Drive orderBy clause (default "folder,name").' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
async handler(args) {
|
|
72
|
+
const drive = await plugin.requireClient();
|
|
73
|
+
const result = await drive.list({
|
|
74
|
+
folderId: args.folderId || 'root',
|
|
75
|
+
pageSize: args.pageSize ?? 50,
|
|
76
|
+
pageToken: args.pageToken || null,
|
|
77
|
+
orderBy: args.orderBy || 'folder,name',
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
folderId: result.folderId,
|
|
82
|
+
count: result.files.length,
|
|
83
|
+
files: result.files.map((file) => ({ ...file, sizeLabel: formatBytes(file.size) })),
|
|
84
|
+
nextPageToken: result.nextPageToken,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
defineTool({
|
|
90
|
+
name: 'drive_get_metadata',
|
|
91
|
+
description:
|
|
92
|
+
'Get full metadata for one Drive file or folder: id, name, MIME type, size, created/modified time, parent folders and web view link.',
|
|
93
|
+
risk: 'read',
|
|
94
|
+
schema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { fileId: { type: 'string', description: 'Google Drive file or folder id.' } },
|
|
97
|
+
required: ['fileId'],
|
|
98
|
+
},
|
|
99
|
+
async handler(args) {
|
|
100
|
+
const drive = await plugin.requireClient();
|
|
101
|
+
const meta = await drive.getMetadata({ fileId: args.fileId });
|
|
102
|
+
return { ok: true, ...meta, sizeLabel: formatBytes(meta.size) };
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
defineTool({
|
|
107
|
+
name: 'drive_download',
|
|
108
|
+
description:
|
|
109
|
+
'Download a Drive file to a local path inside the workspace. The destination must be relative to the workspace root; an existing file is not overwritten unless overwrite=true.',
|
|
110
|
+
risk: 'write',
|
|
111
|
+
requiresApproval: true,
|
|
112
|
+
schema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
fileId: { type: 'string', description: 'Google Drive file id to download.' },
|
|
116
|
+
destinationPath: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: 'Local destination file path relative to the workspace root, e.g. "downloads/report.pdf".',
|
|
119
|
+
},
|
|
120
|
+
overwrite: { type: 'boolean', description: 'Replace an existing local file. Default false.' },
|
|
121
|
+
},
|
|
122
|
+
required: ['fileId', 'destinationPath'],
|
|
123
|
+
},
|
|
124
|
+
async handler(args, ctx) {
|
|
125
|
+
const drive = await plugin.requireClient();
|
|
126
|
+
const target = resolveLocal(ctx, plugin, args.destinationPath);
|
|
127
|
+
if (!args.overwrite) {
|
|
128
|
+
const exists = await fsp.stat(target.realPath).then(() => true).catch(() => false);
|
|
129
|
+
if (exists) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: {
|
|
133
|
+
code: 'DESTINATION_EXISTS',
|
|
134
|
+
message: `File lokal sudah ada: ${target.relPath}. Kirim overwrite=true bila memang ingin menggantinya.`,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const result = await drive.downloadFile({ fileId: args.fileId, destination: target.realPath });
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
fileId: result.id,
|
|
143
|
+
name: result.name,
|
|
144
|
+
mimeType: result.mimeType,
|
|
145
|
+
destinationPath: target.relPath,
|
|
146
|
+
bytes: result.bytes,
|
|
147
|
+
sizeLabel: formatBytes(result.bytes),
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
defineTool({
|
|
153
|
+
name: 'drive_upload',
|
|
154
|
+
description:
|
|
155
|
+
'Upload a local workspace file to Google Drive with streaming (the file is never buffered in memory). Large uploads are capped by maxUploadSize in the plugin config.',
|
|
156
|
+
risk: 'write',
|
|
157
|
+
requiresApproval: true,
|
|
158
|
+
schema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
filePath: { type: 'string', description: 'Local file path relative to the workspace root.' },
|
|
162
|
+
fileName: { type: 'string', description: 'Name to use on Drive. Defaults to the local file name.' },
|
|
163
|
+
folderId: { type: 'string', description: 'Destination folder id. Omit for My Drive root.' },
|
|
164
|
+
mimeType: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
description: 'Content type to store, e.g. application/zip, application/pdf, text/plain.',
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
required: ['filePath'],
|
|
170
|
+
},
|
|
171
|
+
async handler(args, ctx) {
|
|
172
|
+
const drive = await plugin.requireClient();
|
|
173
|
+
const settings = await plugin.loadConfig();
|
|
174
|
+
const source = resolveLocal(ctx, plugin, args.filePath, { mustExist: true });
|
|
175
|
+
const stat = await fsp.stat(source.realPath);
|
|
176
|
+
if (!stat.isFile()) {
|
|
177
|
+
return { ok: false, error: { code: 'NOT_A_FILE', message: `Bukan file biasa: ${source.relPath}` } };
|
|
178
|
+
}
|
|
179
|
+
if (settings.maxUploadSize && stat.size > settings.maxUploadSize) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: {
|
|
183
|
+
code: 'UPLOAD_TOO_LARGE',
|
|
184
|
+
message: `Ukuran ${formatBytes(stat.size)} melebihi maxUploadSize ${formatBytes(settings.maxUploadSize)}. Naikkan lewat /drive config set maxUploadSize <byte>.`,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const uploaded = await drive.uploadFile({
|
|
189
|
+
filePath: source.realPath,
|
|
190
|
+
fileName: args.fileName || null,
|
|
191
|
+
folderId: args.folderId || 'root',
|
|
192
|
+
mimeType: args.mimeType || 'application/octet-stream',
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
file: { ...uploaded, sizeLabel: formatBytes(uploaded.size) },
|
|
197
|
+
localPath: source.relPath,
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
|
|
202
|
+
defineTool({
|
|
203
|
+
name: 'drive_create_folder',
|
|
204
|
+
description: 'Create a new Google Drive folder inside a parent folder.',
|
|
205
|
+
risk: 'write',
|
|
206
|
+
requiresApproval: true,
|
|
207
|
+
schema: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
properties: {
|
|
210
|
+
name: { type: 'string', description: 'Folder name.' },
|
|
211
|
+
parentId: { type: 'string', description: 'Parent folder id. Omit for My Drive root.' },
|
|
212
|
+
},
|
|
213
|
+
required: ['name'],
|
|
214
|
+
},
|
|
215
|
+
async handler(args) {
|
|
216
|
+
const drive = await plugin.requireClient();
|
|
217
|
+
const folder = await drive.createFolder({ name: args.name, parentId: args.parentId || 'root' });
|
|
218
|
+
return { ok: true, folder };
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
|
|
222
|
+
defineTool({
|
|
223
|
+
name: 'drive_move',
|
|
224
|
+
description: 'Move a Drive file or folder into another folder (the previous parents are removed).',
|
|
225
|
+
risk: 'write',
|
|
226
|
+
requiresApproval: true,
|
|
227
|
+
schema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
fileId: { type: 'string', description: 'Item id to move.' },
|
|
231
|
+
toFolderId: { type: 'string', description: 'Destination folder id.' },
|
|
232
|
+
},
|
|
233
|
+
required: ['fileId', 'toFolderId'],
|
|
234
|
+
},
|
|
235
|
+
async handler(args) {
|
|
236
|
+
const drive = await plugin.requireClient();
|
|
237
|
+
await drive.assertFolder({ folderId: args.toFolderId });
|
|
238
|
+
return { ok: true, file: await drive.move({ fileId: args.fileId, toFolderId: args.toFolderId }) };
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
defineTool({
|
|
243
|
+
name: 'drive_rename',
|
|
244
|
+
description: 'Rename a Google Drive file or folder without moving it.',
|
|
245
|
+
risk: 'write',
|
|
246
|
+
requiresApproval: true,
|
|
247
|
+
schema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
fileId: { type: 'string', description: 'Item id to rename.' },
|
|
251
|
+
name: { type: 'string', description: 'New name.' },
|
|
252
|
+
},
|
|
253
|
+
required: ['fileId', 'name'],
|
|
254
|
+
},
|
|
255
|
+
async handler(args) {
|
|
256
|
+
const drive = await plugin.requireClient();
|
|
257
|
+
return { ok: true, file: await drive.rename({ fileId: args.fileId, name: args.name }) };
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
|
|
261
|
+
defineTool({
|
|
262
|
+
name: 'drive_copy',
|
|
263
|
+
description: 'Copy a Google Drive file, optionally into another folder and under a new name.',
|
|
264
|
+
risk: 'write',
|
|
265
|
+
requiresApproval: true,
|
|
266
|
+
schema: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
properties: {
|
|
269
|
+
fileId: { type: 'string', description: 'File id to copy (folders cannot be copied).' },
|
|
270
|
+
name: { type: 'string', description: 'Name for the copy. Defaults to Drive behaviour ("Copy of …").' },
|
|
271
|
+
folderId: { type: 'string', description: 'Destination folder id for the copy.' },
|
|
272
|
+
},
|
|
273
|
+
required: ['fileId'],
|
|
274
|
+
},
|
|
275
|
+
async handler(args) {
|
|
276
|
+
const drive = await plugin.requireClient();
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
file: await drive.copy({ fileId: args.fileId, name: args.name || null, folderId: args.folderId || null }),
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
|
|
284
|
+
defineTool({
|
|
285
|
+
name: 'drive_delete',
|
|
286
|
+
description:
|
|
287
|
+
'Permanently delete a Drive file or folder. This cannot be undone from Ryuu. Only call it after the user explicitly confirmed which file to delete.',
|
|
288
|
+
risk: 'destructive',
|
|
289
|
+
requiresApproval: true,
|
|
290
|
+
assess: (args) => (String(args?.fileId || '') ? 'critical' : 'normal'),
|
|
291
|
+
schema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
fileId: { type: 'string', description: 'Drive file or folder id to delete.' },
|
|
295
|
+
},
|
|
296
|
+
required: ['fileId'],
|
|
297
|
+
},
|
|
298
|
+
preview: async (args) => {
|
|
299
|
+
try {
|
|
300
|
+
const drive = await plugin.getClient();
|
|
301
|
+
const meta = await drive.getMetadata({ fileId: args.fileId });
|
|
302
|
+
return `Hapus permanen: ${meta.name} (${meta.isFolder ? 'folder' : formatBytes(meta.size)})\nid: ${meta.id}\n${fileLine(meta)}`;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return `Hapus permanen id ${args.fileId} (metadata tidak terbaca: ${error.message})`;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
async handler(args) {
|
|
308
|
+
const drive = await plugin.requireClient();
|
|
309
|
+
const meta = await drive.getMetadata({ fileId: args.fileId });
|
|
310
|
+
await drive.remove({ fileId: args.fileId });
|
|
311
|
+
return { ok: true, deleted: true, file: { id: meta.id, name: meta.name, isFolder: meta.isFolder } };
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createFileTools } from './tools-files.js';
|
|
2
|
+
import { createBackupTools } from './tools-backup.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The complete AI tool set of the google-drive plugin.
|
|
6
|
+
*
|
|
7
|
+
* Every definition uses the registry's own `defineTool` contract (name,
|
|
8
|
+
* description, JSON schema, risk, handler), so the providers that already
|
|
9
|
+
* support Ryuu tool calling get these tools with no extra wiring.
|
|
10
|
+
*/
|
|
11
|
+
export function createDriveTools(plugin) {
|
|
12
|
+
return [...createFileTools(plugin), ...createBackupTools(plugin)];
|
|
13
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { RyuuError } from '../../src/utils/errors.js';
|
|
7
|
+
import { createCrc32 } from './zip-writer.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Streaming ZIP reader with Zip Slip protection.
|
|
11
|
+
*
|
|
12
|
+
* The central directory is parsed once (it is small), then each entry is
|
|
13
|
+
* inflated straight from a bounded slice of the archive into its destination
|
|
14
|
+
* file. Every path is re-validated against the extraction root, and CRC32 is
|
|
15
|
+
* checked, so a corrupted or hostile archive fails before it can write outside
|
|
16
|
+
* the destination directory.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SIG_EOCD = 0x06054b50;
|
|
20
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
21
|
+
const SIG_LOCAL = 0x04034b50;
|
|
22
|
+
|
|
23
|
+
function zipError(message, extra = {}) {
|
|
24
|
+
return new RyuuError(message, { code: 'ZIP_INVALID', ...extra });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reject absolute paths, `..` segments and drive letters (Zip Slip). */
|
|
28
|
+
export function assertSafeEntryName(name) {
|
|
29
|
+
const raw = String(name).replace(/\\/g, '/');
|
|
30
|
+
if (!raw || raw.endsWith('/')) return null; // directory entry
|
|
31
|
+
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) {
|
|
32
|
+
throw zipError(`Arsip berisi path absolut dan ditolak: ${raw}`);
|
|
33
|
+
}
|
|
34
|
+
const segments = raw.split('/');
|
|
35
|
+
if (segments.some((segment) => segment === '..')) {
|
|
36
|
+
throw zipError(`Arsip berisi path traversal dan ditolak: ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
const clean = segments.filter((segment) => segment && segment !== '.').join('/');
|
|
39
|
+
if (!clean) return null;
|
|
40
|
+
return clean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve an entry against the extraction root, refusing any escape. */
|
|
44
|
+
export function resolveInside(root, entryName) {
|
|
45
|
+
const clean = assertSafeEntryName(entryName);
|
|
46
|
+
if (!clean) return null;
|
|
47
|
+
const target = path.resolve(root, clean);
|
|
48
|
+
const relative = path.relative(root, target);
|
|
49
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
50
|
+
throw zipError(`Entri arsip menulis di luar direktori tujuan: ${entryName}`);
|
|
51
|
+
}
|
|
52
|
+
return { relative: relative.split(path.sep).join('/'), target };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readEocd(handle, size) {
|
|
56
|
+
const tailLength = Math.min(size, 66 * 1024);
|
|
57
|
+
const buffer = Buffer.alloc(tailLength);
|
|
58
|
+
await handle.read(buffer, 0, tailLength, size - tailLength);
|
|
59
|
+
for (let i = buffer.length - 22; i >= 0; i -= 1) {
|
|
60
|
+
if (buffer.readUInt32LE(i) === SIG_EOCD) {
|
|
61
|
+
return {
|
|
62
|
+
entryCount: buffer.readUInt16LE(i + 10),
|
|
63
|
+
centralSize: buffer.readUInt32LE(i + 12),
|
|
64
|
+
centralOffset: buffer.readUInt32LE(i + 16),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw zipError('Arsip bukan ZIP yang valid (end of central directory tidak ditemukan).');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readCentralDirectory(handle, eocd) {
|
|
72
|
+
if (eocd.centralSize === 0) return [];
|
|
73
|
+
const buffer = Buffer.alloc(eocd.centralSize);
|
|
74
|
+
await handle.read(buffer, 0, eocd.centralSize, eocd.centralOffset);
|
|
75
|
+
const entries = [];
|
|
76
|
+
let offset = 0;
|
|
77
|
+
while (offset + 46 <= buffer.length && buffer.readUInt32LE(offset) === SIG_CENTRAL) {
|
|
78
|
+
const flags = buffer.readUInt16LE(offset + 8);
|
|
79
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
80
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
81
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
82
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
|
83
|
+
entries.push({
|
|
84
|
+
name,
|
|
85
|
+
flags,
|
|
86
|
+
method: buffer.readUInt16LE(offset + 10),
|
|
87
|
+
crc32: buffer.readUInt32LE(offset + 16),
|
|
88
|
+
compressedSize: buffer.readUInt32LE(offset + 20),
|
|
89
|
+
uncompressedSize: buffer.readUInt32LE(offset + 24),
|
|
90
|
+
localOffset: buffer.readUInt32LE(offset + 42),
|
|
91
|
+
});
|
|
92
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read an archive's table of contents without extracting anything. */
|
|
98
|
+
export async function listZipEntries(zipPath) {
|
|
99
|
+
let handle;
|
|
100
|
+
try {
|
|
101
|
+
handle = await fsp.open(zipPath, 'r');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw zipError(`Arsip tidak dapat dibuka: ${zipPath} (${error.message})`);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const { size } = await handle.stat();
|
|
107
|
+
if (size < 22) throw zipError('Arsip terlalu kecil untuk menjadi ZIP yang valid.');
|
|
108
|
+
const eocd = await readEocd(handle, size);
|
|
109
|
+
return await readCentralDirectory(handle, eocd);
|
|
110
|
+
} finally {
|
|
111
|
+
await handle.close().catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract `zipPath` into `destination`.
|
|
117
|
+
*
|
|
118
|
+
* `overwrite: false` refuses to replace an existing file instead of silently
|
|
119
|
+
* clobbering it. A symlink at the target path is always refused, even with
|
|
120
|
+
* overwrite, because writing through it would escape the destination.
|
|
121
|
+
*/
|
|
122
|
+
export async function extractZip({
|
|
123
|
+
zipPath,
|
|
124
|
+
destination,
|
|
125
|
+
overwrite = false,
|
|
126
|
+
entries: only = null,
|
|
127
|
+
maxTotalBytes = 8 * 1024 * 1024 * 1024,
|
|
128
|
+
onProgress = null,
|
|
129
|
+
signal = null,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const root = path.resolve(destination);
|
|
132
|
+
await fsp.mkdir(root, { recursive: true, mode: 0o700 });
|
|
133
|
+
const handle = await fsp.open(zipPath, 'r');
|
|
134
|
+
const written = [];
|
|
135
|
+
let totalBytes = 0;
|
|
136
|
+
try {
|
|
137
|
+
const { size } = await handle.stat();
|
|
138
|
+
const eocd = await readEocd(handle, size);
|
|
139
|
+
const entries = await readCentralDirectory(handle, eocd);
|
|
140
|
+
const wanted = only ? new Set(only) : null;
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (signal?.aborted) throw new RyuuError('Restore dibatalkan.', { code: 'CANCELLED' });
|
|
143
|
+
if (entry.flags & 0x1) throw zipError('Arsip terenkripsi tidak didukung.');
|
|
144
|
+
if (entry.method !== 0 && entry.method !== 8) {
|
|
145
|
+
throw zipError(`Metode kompresi ${entry.method} tidak didukung pada entri ${entry.name}.`);
|
|
146
|
+
}
|
|
147
|
+
const resolved = resolveInside(root, entry.name);
|
|
148
|
+
if (!resolved) continue;
|
|
149
|
+
if (wanted && !wanted.has(resolved.relative)) continue;
|
|
150
|
+
|
|
151
|
+
// A symlink anywhere on the path would let the write escape the root.
|
|
152
|
+
const parent = path.dirname(resolved.target);
|
|
153
|
+
await fsp.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
154
|
+
try {
|
|
155
|
+
const existing = await fsp.lstat(resolved.target);
|
|
156
|
+
if (existing.isSymbolicLink()) {
|
|
157
|
+
throw zipError(`Tujuan ekstraksi adalah symlink dan ditolak: ${resolved.relative}`);
|
|
158
|
+
}
|
|
159
|
+
if (existing.isDirectory()) {
|
|
160
|
+
throw zipError(`Tujuan ekstraksi adalah direktori, bukan file: ${resolved.relative}`);
|
|
161
|
+
}
|
|
162
|
+
if (!overwrite) {
|
|
163
|
+
throw new RyuuError(
|
|
164
|
+
`File sudah ada dan tidak ditimpa: ${resolved.relative}. Jalankan ulang dengan overwrite=true bila memang ingin menggantinya.`,
|
|
165
|
+
{ code: 'RESTORE_EXISTS' },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error.code !== 'ENOENT') throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const headerBuffer = Buffer.alloc(30);
|
|
173
|
+
await handle.read(headerBuffer, 0, 30, entry.localOffset);
|
|
174
|
+
if (headerBuffer.readUInt32LE(0) !== SIG_LOCAL) {
|
|
175
|
+
throw zipError(`Local header tidak valid pada entri ${entry.name}.`);
|
|
176
|
+
}
|
|
177
|
+
const nameLength = headerBuffer.readUInt16LE(26);
|
|
178
|
+
const extraLength = headerBuffer.readUInt16LE(28);
|
|
179
|
+
const dataStart = entry.localOffset + 30 + nameLength + extraLength;
|
|
180
|
+
if (entry.compressedSize === 0) {
|
|
181
|
+
await fsp.writeFile(resolved.target, Buffer.alloc(0), { mode: 0o600 });
|
|
182
|
+
written.push({ path: resolved.relative, bytes: 0 });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (dataStart + entry.compressedSize > size) {
|
|
186
|
+
throw zipError(`Entri ${entry.name} melewati akhir arsip (arsip rusak).`);
|
|
187
|
+
}
|
|
188
|
+
const source = handle.createReadStream({ start: dataStart, end: dataStart + entry.compressedSize - 1, autoClose: false });
|
|
189
|
+
const crc = createCrc32();
|
|
190
|
+
const sink = fs.createWriteStream(resolved.target, { mode: 0o600 });
|
|
191
|
+
const meter = async function* meter(stream) {
|
|
192
|
+
for await (const chunk of stream) {
|
|
193
|
+
crc.update(chunk);
|
|
194
|
+
totalBytes += chunk.length;
|
|
195
|
+
if (totalBytes > maxTotalBytes) {
|
|
196
|
+
throw zipError('Ukuran hasil ekstraksi melebihi batas keamanan (8 GB).');
|
|
197
|
+
}
|
|
198
|
+
if (onProgress) onProgress({ path: resolved.relative, loaded: totalBytes });
|
|
199
|
+
yield chunk;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
if (entry.method === 8) {
|
|
203
|
+
await pipeline(source, zlib.createInflateRaw(), meter, sink);
|
|
204
|
+
} else {
|
|
205
|
+
await pipeline(source, meter, sink);
|
|
206
|
+
}
|
|
207
|
+
if (crc.digest() !== entry.crc32) {
|
|
208
|
+
await fsp.rm(resolved.target, { force: true }).catch(() => {});
|
|
209
|
+
throw zipError(`CRC arsip tidak cocok pada entri ${entry.name} (arsip rusak).`);
|
|
210
|
+
}
|
|
211
|
+
written.push({ path: resolved.relative, bytes: entry.uncompressedSize });
|
|
212
|
+
}
|
|
213
|
+
return { destination: root, files: written, fileCount: written.length, bytes: totalBytes };
|
|
214
|
+
} finally {
|
|
215
|
+
await handle.close().catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
}
|