proton-drive-mcp 1.3.3 → 1.4.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 +11 -0
- package/README.md +24 -4
- package/dist/cli.js +16 -0
- package/dist/index.js +4 -3
- package/dist/services/analytics.js +4 -4
- package/dist/services/find.js +3 -3
- package/dist/services/plan.js +3 -2
- package/dist/services/walk.js +294 -38
- package/dist/services/walkIndex.js +187 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 — 2026-10-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Persistent search index (opt-in).** Every new server process used to walk the whole drive again on its first `drive_search`, `drive_tree`, `drive_usage` or `drive_find_duplicates` (about 30 s for 1,200 files, minutes on big drives). With `PROTON_DRIVE_INDEX=1` (or the **Persistent search index** switch in the MCPB bundle) the last complete walk is saved to disk and a new process answers in about 1.5 s with `stale: true` while a background walk refreshes it. Verified on a real 1,222-file drive: cold 31–34 s, next process 1.3–2.1 s.
|
|
7
|
+
- **Privacy:** the index is a plaintext copy of file and folder names, paths, sizes, dates and claimed sha1 hashes of your end-to-end-encrypted drive, so it is off by default. It lives in a private directory (`~/Library/Caches/proton-drive-mcp` on macOS, `~/.cache/proton-drive-mcp` on Linux, or `PROTON_DRIVE_INDEX_DIR`), file mode 0600, written atomically; symlinks, foreign owners and loose permissions are refused, and a pre-existing directory is never taken over. `proton-drive-cli index status` and `index clear` inspect and remove it.
|
|
8
|
+
- **Safety:** before saved data is served, one `list /my-files` confirms it belongs to the logged-in account (a failed check serves nothing; a mismatch deletes the file); data older than `PROTON_DRIVE_INDEX_MAX_AGE_H` (default 24) is ignored; the server's own writes (upload, move, trash, ...) remove the affected entries from memory and disk, and an index that cannot be kept current (read-only directory) is not served. `drive_sync_plan`, `drive_find_duplicates` and `drive_sharing_audit` never use saved data.
|
|
9
|
+
- Results served from the index carry `stale: true` (and `refreshing: true` while a refresh runs); `refresh: true` always walks.
|
|
10
|
+
|
|
11
|
+
### Known limitations
|
|
12
|
+
- `drive_tree` with a depth limit is not answered from the saved full walk, and changes made by other clients or processes are not seen until the saved data is refreshed. The refresh cooldown after a failed background walk is per server process.
|
|
13
|
+
|
|
3
14
|
## 1.3.3 — 2026-10-01
|
|
4
15
|
|
|
5
16
|
### Fixed
|
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Restart Claude Desktop. Check **`+` → Connectors → proton-drive-mcp** to con
|
|
|
108
108
|
|
|
109
109
|
### One-click install (MCPB bundle)
|
|
110
110
|
|
|
111
|
-
Instead of editing JSON, download `proton-drive-mcp-<version>.mcpb` from the [latest GitHub release](https://github.com/googlarz/proton-drive-mcp/releases/latest) and open it (or drag it into **Settings → Extensions** in Claude Desktop). In the extension settings, set **proton-drive CLI path** to the output of `which proton-drive` (usually `~/.local/bin/proton-drive`); optionally set the **Proton Drive sync folder** to enable `drive_read_file` / `drive_write_file
|
|
111
|
+
Instead of editing JSON, download `proton-drive-mcp-<version>.mcpb` from the [latest GitHub release](https://github.com/googlarz/proton-drive-mcp/releases/latest) and open it (or drag it into **Settings → Extensions** in Claude Desktop). In the extension settings, set **proton-drive CLI path** to the output of `which proton-drive` (usually `~/.local/bin/proton-drive`); optionally set the **Proton Drive sync folder** to enable `drive_read_file` / `drive_write_file`, and the **Persistent search index** switch (off by default; it stores file names in plain text on this computer, see [Persistent index](#persistent-index-opt-in)). You still need the official `proton-drive` CLI installed and `proton-drive auth login` done once.
|
|
112
112
|
|
|
113
113
|
### If installed globally
|
|
114
114
|
|
|
@@ -241,16 +241,33 @@ proton-drive-cli share audit /my-files
|
|
|
241
241
|
proton-drive-cli sync-plan ./local /my-files/backup --direction up
|
|
242
242
|
proton-drive-cli bulk-move /my-files/Archive /my-files/a.pdf /my-files/b.pdf # plan only
|
|
243
243
|
proton-drive-cli bulk-trash /my-files/old1.txt /my-files/old2.txt --confirm # apply
|
|
244
|
+
proton-drive-cli index status # persistent index: enabled?, path, size, age, entries
|
|
245
|
+
proton-drive-cli index clear # delete the saved index file
|
|
244
246
|
```
|
|
245
247
|
|
|
246
|
-
- The first walk of a large drive can take minutes (measured ~25 s per 1,200 files at concurrency 8). Later calls reuse a 5-minute cache; `refresh` bypasses it.
|
|
248
|
+
- The first walk of a large drive can take minutes (measured ~25 s per 1,200 files at concurrency 8). Later calls reuse a 5-minute in-memory cache; `refresh` bypasses it. With the opt-in persistent index, a saved copy up to 24 h old can answer first (marked `stale: true`).
|
|
247
249
|
- Walks skip `.git` and `node_modules` by default.
|
|
250
|
+
- With `PROTON_DRIVE_INDEX=1`, results may come from a saved index (`stale: true` = served from the saved index, not a walk made now) while a refresh runs; repeat the call or pass `refresh: true` for a fresh read. Off by default, see [Persistent index](#persistent-index-opt-in).
|
|
248
251
|
- Read-only tools (`drive_search`, `drive_tree`, `drive_usage`, `drive_find_duplicates`, `drive_sharing_audit`, `drive_sync_plan`) change nothing. `drive_bulk_move` and `drive_bulk_trash` are two-step: call without `confirmed` for the plan, then with `confirmed: true` to apply. Bulk trash is reversible with `drive_restore`.
|
|
249
252
|
- Duplicate detection uses the sha1 the uploader claimed, which is unverified. `verify: true` downloads the candidates and hashes them.
|
|
250
253
|
- The sharing audit flags an invitee as external when the address is outside Proton domains, so Proton users on a custom domain are over-flagged.
|
|
251
254
|
- Sizes are sums of file sizes, not your account quota.
|
|
252
255
|
- `drive_usage` trash stats always make one uncached `/trash` listing (~2.5 s), even when the walk is cached.
|
|
253
256
|
|
|
257
|
+
### Persistent index (opt-in)
|
|
258
|
+
|
|
259
|
+
By default every new server process (each Claude Desktop session) walks the drive again on the first `drive_search`, `drive_tree`, `drive_usage` or `drive_find_duplicates`: about 25 s per 1,200 files, minutes on big drives. Set `PROTON_DRIVE_INDEX=1` to keep completed walks on disk and answer instantly after a restart.
|
|
260
|
+
|
|
261
|
+
**Privacy: this is off by default.** Your Drive is end-to-end encrypted, but the index is a **plaintext** copy of file and folder names, paths, sizes, dates and claimed sha1 hashes on your local disk. Anyone who can read your user account's files (or your backups) can read it. Enable it only on a machine you trust.
|
|
262
|
+
|
|
263
|
+
- **What is stored:** the node list of up to 8 recent complete walks under `/my-files` (partial or failed walks are never saved, so a `drive_tree` that stops at its depth limit is not stored), plus a fingerprint of your account's Drive root. No file contents, no credentials, no tokens.
|
|
264
|
+
- **Where:** `PROTON_DRIVE_INDEX_DIR` if set; otherwise `~/Library/Caches/proton-drive-mcp` (macOS) or `$XDG_CACHE_HOME/proton-drive-mcp` / `~/.cache/proton-drive-mcp` (Linux). One file, `walk-index.json`, mode `0600` in a `0700` directory, written atomically. Use a dedicated directory: a pre-existing directory with group/other access (or owned by someone else) is refused with one warning and never chmodded; a missing one is created `0700`. A symlinked directory or file is refused. A saved file with loose permissions, another owner, a future timestamp, or above 200 MB is ignored. Not written above 200 MB.
|
|
265
|
+
- **Account check:** before any saved data is served, one `list /my-files` (~2 s) confirms it belongs to the account that is logged in now; if the check fails (network, rate limit) nothing is served from disk for that call and the file is kept; a proven account mismatch deletes it. Either way the drive is walked fresh.
|
|
266
|
+
- **Freshness:** data up to `PROTON_DRIVE_INDEX_MAX_AGE_H` hours old (default 24; `0` = never use saved data) is returned immediately with `stale: true` (and `refreshing: true` while one background walk replaces it); a repeated call then returns the fresh walk. A failed or incomplete background walk is not retried for 5 minutes within the same server process (`PROTON_DRIVE_INDEX_REFRESH_COOLDOWN_MS`); a new process tries once again. Saved data is first checked against the live account with one `list /my-files`; if that check fails (network, rate limit) nothing is served from disk for that call and the file is kept, and only a proven account mismatch deletes it. `drive_sync_plan`, `drive_find_duplicates` and the sharing audit never use saved data. `refresh: true` always does a blocking fresh walk. Writes made through this server drop the affected entries from memory and disk.
|
|
267
|
+
- **Read-only directory:** an index in a directory (or file) you cannot write to is ignored, because it could not be kept current after the server's own writes.
|
|
268
|
+
- **Limits:** `drive_tree` with a depth limit is not answered from the saved full walk (it walks live); changes made by other clients or processes are not seen until the data is refreshed.
|
|
269
|
+
- **Check or remove it:** `proton-drive-cli index status` and `proton-drive-cli index clear` (or just delete the file). In the MCPB bundle, the **Persistent search index** setting is the opt-in.
|
|
270
|
+
|
|
254
271
|
### Photos
|
|
255
272
|
|
|
256
273
|
```bash
|
|
@@ -396,14 +413,14 @@ The server offers four MCP prompts (also declared in the MCPB manifest): `organi
|
|
|
396
413
|
- `drive_move` accepts a full destination path (parent + new name) for a familiar interface, but the underlying CLI only has separate `move` (change parent) and `rename` (change name) commands — this MCP translates automatically, issuing one or both as needed.
|
|
397
414
|
- `drive_delete` only works on items already in `/trash` or `/photos-trash` — the CLI rejects live paths. Trash an item first with `drive_trash`, or use `drive_empty_trash` to clear everything at once.
|
|
398
415
|
- If the client declares MCP elicitation (form mode), a refused `confirmed` gate is put to the human instead, and `drive_bulk_move` / `drive_bulk_trash` ask before applying a `confirmed: true` call. Only an explicit approval runs the call; decline, cancel, timeout or error keep the refusal, and after 5 declined prompts in a minute the server stops prompting. This only upgrades refusals and bulk applies: a compromised model that supplies `confirmed: true` on any other tool is **not** stopped by it. Clients without elicitation behave as before. Confirmation prompts need a client that supports elicitation (Claude Code CLI today; Claude Desktop is unverified).
|
|
399
|
-
- Walk-based tools (`drive_tree`, `drive_search`, `drive_usage`, `drive_find_duplicates`) read from a cache up to 5 minutes old; changes made by other clients or processes are not seen until it expires or you pass `refresh: true`. The first walk of a large drive can take minutes (measured about 25 s per 1200 files at concurrency 8).
|
|
416
|
+
- Walk-based tools (`drive_tree`, `drive_search`, `drive_usage`, `drive_find_duplicates`) read from a cache up to 5 minutes old (with the opt-in persistent index, up to 24 h old and marked `stale: true`; `drive_sharing_audit`, `drive_sync_plan` and `drive_find_duplicates` never use the saved index); changes made by other clients or processes are not seen until it expires or you pass `refresh: true`. The first walk of a large drive can take minutes (measured about 25 s per 1200 files at concurrency 8).
|
|
400
417
|
- `drive_auth_status` has no native CLI equivalent — it probes by resolving `/my-files` and reports authenticated based on whether that succeeds.
|
|
401
418
|
- Paths are always Drive-absolute: `/my-files/folder/file.pdf`. Relative paths are not supported.
|
|
402
419
|
- All calls include `--json` automatically, except `drive_version`, whose underlying CLI command ignores `--json` and always prints plain text — this MCP parses it directly.
|
|
403
420
|
|
|
404
421
|
### Token cost
|
|
405
422
|
|
|
406
|
-
`tools/list` is sent to the model in every session. Measured payload (JSON bytes): `full` 39.
|
|
423
|
+
`tools/list` is sent to the model in every session. Measured payload (JSON bytes): `full` 39.2 KB (46 tools), `core` 17.0 KB (18 tools). Set `PROTON_DRIVE_TOOL_TIER=core` to load only:
|
|
407
424
|
|
|
408
425
|
`drive_auth_status`, `drive_version`, `drive_list`, `drive_info`, `drive_list_trash`, `drive_search`, `drive_tree`, `drive_mkdir`, `drive_upload`, `drive_download`, `drive_rename`, `drive_move`, `drive_copy`, `drive_trash`, `drive_restore`, `drive_share_status`, `photos_list_timeline`, `photos_download`.
|
|
409
426
|
|
|
@@ -446,6 +463,9 @@ Every tool group was live-tested on 2026-09-28 against a real Proton account, **
|
|
|
446
463
|
| `CLAUDE_DESKTOP_CONFIG` | Optional | Path of the Claude Desktop config that `doctor` and `setup-claude-desktop` read/write instead of the per-OS default. |
|
|
447
464
|
| `PROTON_DRIVE_RETRY_BASE_MS` | Optional | Test hook: base backoff in ms for retrying read-only calls (default 250). |
|
|
448
465
|
| `PROTON_DRIVE_WALK_TTL_MS` | Optional | How long `drive_tree`/`drive_search` reuse a cached folder walk, in ms (default 300000; 0 disables the cache). Results can be up to this old, and changes made by other clients or processes are not seen until it expires or `refresh: true`. The first walk of a large drive can take minutes. The cache is dropped for any path this server writes to. |
|
|
466
|
+
| `PROTON_DRIVE_INDEX` | Optional | Set to `1` (or `true`) to keep completed folder walks in a **plaintext** on-disk index so searches are fast after a restart. Off by default. See [Persistent index](#persistent-index-opt-in). |
|
|
467
|
+
| `PROTON_DRIVE_INDEX_DIR` | Optional | Directory for the index file (default: `~/Library/Caches/proton-drive-mcp` on macOS, `$XDG_CACHE_HOME` or `~/.cache/proton-drive-mcp` elsewhere). Use a dedicated directory: if it exists it must be yours with mode `0700` (group/other access is refused); a missing one is created `0700`. The file is `0600`. |
|
|
468
|
+
| `PROTON_DRIVE_INDEX_MAX_AGE_H` | Optional | Oldest saved index data that may be served, in hours (default 24; 0 = never serve saved data). Served data is marked `stale: true` and refreshed in the background (`drive_sync_plan`, `drive_find_duplicates` and the sharing audit never use it). |
|
|
449
469
|
| `PROTON_DRIVE_TOOL_TIER` | Optional | `full` (default, all 46 tools) or `core` (18 everyday tools; see [Token cost](#token-cost)). Tools outside the active tier are hidden from `tools/list` and refused at call time. Read once at startup; an unknown value falls back to `full` with a warning on stderr. |
|
|
450
470
|
|
|
451
471
|
---
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { runDoctor, formatDoctor } from "./utils/doctor.js";
|
|
|
13
13
|
import { buildEntry, defaultConfigPath, isDirectory, resolveDriveCli, writeEntry, SERVER_KEY } from "./utils/claudeConfig.js";
|
|
14
14
|
import { syncPlan, planBulkMove, planBulkTrash, loadListing, foldersToList } from "./services/plan.js";
|
|
15
15
|
import { invalidatePath } from "./services/walk.js";
|
|
16
|
+
import { indexStatus, deleteIndex } from "./services/walkIndex.js";
|
|
16
17
|
import { resolve } from "node:path";
|
|
17
18
|
import { driveSearch, driveTree } from "./services/find.js";
|
|
18
19
|
import { driveUsage, driveFindDuplicates, driveSharingAudit } from "./services/analytics.js";
|
|
@@ -81,6 +82,9 @@ Commands:
|
|
|
81
82
|
photo download <photo>... <local> [--conflict X] [--confirm] Download photos (skip/rename/remove; 'remove' needs --confirm)
|
|
82
83
|
photo upload <local>... [--conflict X] Upload photos to your library (skip/rename)
|
|
83
84
|
|
|
85
|
+
index status Persistent walk index (PROTON_DRIVE_INDEX=1): enabled?, path, size, age, entries
|
|
86
|
+
index clear Delete the saved index file (it holds plaintext names/paths of your drive)
|
|
87
|
+
|
|
84
88
|
doctor [--config <path>] Read-only diagnostics: Node, proton-drive CLI, auth, sync path, Claude Desktop config
|
|
85
89
|
(exit 1 if any check fails)
|
|
86
90
|
setup-claude-desktop [--config <path>] [--sync-path <dir>] [--write]
|
|
@@ -201,6 +205,18 @@ async function run() {
|
|
|
201
205
|
setupClaudeDesktop();
|
|
202
206
|
return;
|
|
203
207
|
}
|
|
208
|
+
// Local file only: no account, no CLI binary needed.
|
|
209
|
+
if (cmd === "index") {
|
|
210
|
+
if (sub === "status")
|
|
211
|
+
print(indexStatus());
|
|
212
|
+
else if (sub === "clear")
|
|
213
|
+
print(deleteIndex());
|
|
214
|
+
else {
|
|
215
|
+
console.error("Usage: index status|clear");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
204
220
|
const cliCheck = await checkCliAvailable();
|
|
205
221
|
if (!cliCheck.available) {
|
|
206
222
|
const msg = cliCheck.reason === "not_executable"
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { validateRemotePath, validateRemotePathList, validateLocalPath, validate
|
|
|
12
12
|
import { logger } from "./utils/logger.js";
|
|
13
13
|
import { driveSearch, driveTree } from "./services/find.js";
|
|
14
14
|
import { syncPlan, planBulkMove, planBulkTrash, loadListing, foldersToList } from "./services/plan.js";
|
|
15
|
-
import { invalidatePath } from "./services/walk.js";
|
|
15
|
+
import { invalidatePath, abortBackgroundRefreshes } from "./services/walk.js";
|
|
16
16
|
import { getSyncRoot, readSyncFile, writeSyncFile, syncFileExists } from "./utils/syncfs.js";
|
|
17
17
|
import { driveUsage, driveFindDuplicates, driveSharingAudit } from "./services/analytics.js";
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
@@ -195,7 +195,7 @@ const TOOLS = [
|
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
name: "drive_search",
|
|
198
|
-
description: "Find files/folders under a path by name, type, extension, size or date in one walk. Filters are ANDed. Returns {total, hasMore, items [{path, type, size?, mtime?, sha1?}], walk {complete, fromCache, skipped?}}; walk.complete=false means matches may be missing. Cached 5 min; refresh=true re-reads; skips .git/node_modules.",
|
|
198
|
+
description: "Find files/folders under a path by name, type, extension, size or date in one walk. Filters are ANDed. Returns {total, hasMore, items [{path, type, size?, mtime?, sha1?}], walk {complete, fromCache, skipped?}}; walk.complete=false means matches may be missing. Cached 5 min; refresh=true re-reads; skips .git/node_modules. With PROTON_DRIVE_INDEX=1 a saved index may answer first (stale:true); repeat the call or pass refresh:true.",
|
|
199
199
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
200
200
|
inputSchema: {
|
|
201
201
|
type: "object",
|
|
@@ -1558,10 +1558,11 @@ export async function main() {
|
|
|
1558
1558
|
}
|
|
1559
1559
|
});
|
|
1560
1560
|
const shutdown = (code) => {
|
|
1561
|
+
abortBackgroundRefreshes();
|
|
1561
1562
|
killAllChildren();
|
|
1562
1563
|
process.exit(code);
|
|
1563
1564
|
};
|
|
1564
|
-
server.onclose = () => killAllChildren();
|
|
1565
|
+
server.onclose = () => { abortBackgroundRefreshes(); killAllChildren(); };
|
|
1565
1566
|
process.once("SIGTERM", () => shutdown(0));
|
|
1566
1567
|
process.once("SIGINT", () => shutdown(0));
|
|
1567
1568
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
|
|
|
3
3
|
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { walkTree } from "./walk.js";
|
|
6
|
+
import { walkTree, staleFields } from "./walk.js";
|
|
7
7
|
import { validateLocalPath } from "../utils/validation.js";
|
|
8
8
|
import { callContext } from "../utils/subprocess.js";
|
|
9
9
|
const DAY_MS = 86_400_000;
|
|
@@ -35,7 +35,7 @@ function breakdown(nodes, key, top) {
|
|
|
35
35
|
return topBy([...m].map(([k, v]) => ({ key: k, ...v })), top, (r) => r.key);
|
|
36
36
|
}
|
|
37
37
|
function walkMeta(w) {
|
|
38
|
-
return { complete: w.complete, skippedCount: w.skipped.length, skipped: w.skipped.slice(0, 10), fromCache: w.fromCache };
|
|
38
|
+
return { complete: w.complete, skippedCount: w.skipped.length, skipped: w.skipped.slice(0, 10), fromCache: w.fromCache, ...staleFields(w) };
|
|
39
39
|
}
|
|
40
40
|
// ---- drive_usage ----------------------------------------------------------
|
|
41
41
|
export function summarizeTrash(items) {
|
|
@@ -245,7 +245,7 @@ export async function driveFindDuplicates(drive, o, deps = {}) {
|
|
|
245
245
|
if (o.maxVerifyTotalBytes !== undefined && (!Number.isInteger(o.maxVerifyTotalBytes) || o.maxVerifyTotalBytes < 1 || o.maxVerifyTotalBytes > MAX_VERIFY_TOTAL_BYTES)) {
|
|
246
246
|
throw new Error(`maxVerifyTotalBytes must be an integer between 1 and ${MAX_VERIFY_TOTAL_BYTES}`);
|
|
247
247
|
}
|
|
248
|
-
const walk = await (deps.walk ?? walkTree)(drive, o.path, { refresh: o.refresh });
|
|
248
|
+
const walk = await (deps.walk ?? walkTree)(drive, o.path, { refresh: o.refresh, noDisk: true }); // deletion suggestions must come from a current walk
|
|
249
249
|
const limit = o.limit ?? 20;
|
|
250
250
|
let groups = findDuplicateCandidates(walk.nodes, o.minSize ?? 1024);
|
|
251
251
|
let verified = false;
|
|
@@ -292,7 +292,7 @@ export function auditShareStatus(path, s) {
|
|
|
292
292
|
return { path, publicLink, invitees, pending, flags };
|
|
293
293
|
}
|
|
294
294
|
export async function driveSharingAudit(drive, o, deps = {}) {
|
|
295
|
-
const walk = await (deps.walk ?? walkTree)(drive, o.path, { refresh: o.refresh });
|
|
295
|
+
const walk = await (deps.walk ?? walkTree)(drive, o.path, { refresh: o.refresh, noDisk: true }); // a security audit must not read a saved index
|
|
296
296
|
const status = deps.status ?? ((p) => drive.shareStatus(p));
|
|
297
297
|
const shared = walk.nodes.filter((n) => (n.isShared || n.isSharedByUrl) && parentOf(n.path) !== "");
|
|
298
298
|
const targets = shared.slice(0, SHARE_AUDIT_CAP);
|
package/dist/services/find.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { baseName, depthOf, parentPath, DEFAULT_EXCLUDE, walkTree } from "./walk.js";
|
|
1
|
+
import { baseName, depthOf, parentPath, DEFAULT_EXCLUDE, walkTree, staleFields } from "./walk.js";
|
|
2
2
|
import { validateRemotePath } from "../utils/validation.js";
|
|
3
3
|
import { globMatch } from "../utils/glob.js";
|
|
4
4
|
// Argument handling shared by the drive_tree / drive_search tools and the CLI.
|
|
@@ -56,7 +56,7 @@ function root(a) {
|
|
|
56
56
|
}
|
|
57
57
|
function walkInfo(w, scanned, scanCapped) {
|
|
58
58
|
return {
|
|
59
|
-
complete: w.complete, fromCache: w.fromCache, ageMs: w.ageMs, callsMade: w.callsMade, scanned,
|
|
59
|
+
complete: w.complete, fromCache: w.fromCache, ...staleFields(w), ageMs: w.ageMs, callsMade: w.callsMade, scanned,
|
|
60
60
|
...(scanCapped ? { scanCapped: true } : {}),
|
|
61
61
|
...(w.skipped.length ? { skippedCount: w.skipped.length, skipped: w.skipped.slice(0, SKIPPED_SHOWN) } : {}),
|
|
62
62
|
};
|
|
@@ -201,7 +201,7 @@ export async function driveTree(svc, a) {
|
|
|
201
201
|
complete: w.skipped.length === 0,
|
|
202
202
|
...(unexpandedCount ? { unexpanded: unexpandedCount } : {}),
|
|
203
203
|
...(w.skipped.length ? { skippedCount: w.skipped.length, skipped: w.skipped.slice(0, SKIPPED_SHOWN) } : {}),
|
|
204
|
-
fromCache: w.fromCache, ageMs: w.ageMs, callsMade: w.callsMade,
|
|
204
|
+
fromCache: w.fromCache, ...staleFields(w), ageMs: w.ageMs, callsMade: w.callsMade,
|
|
205
205
|
tree: top.children ?? [], ...(top.more ? { more: top.more } : {}),
|
|
206
206
|
};
|
|
207
207
|
}
|
package/dist/services/plan.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { createReadStream, lstatSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { join, relative, sep } from "node:path";
|
|
4
4
|
import { ROOT_PATHS, splitRemotePath, joinRemote } from "./drive.js";
|
|
5
|
-
import { walkTree } from "./walk.js";
|
|
5
|
+
import { walkTree, staleFields } from "./walk.js";
|
|
6
6
|
import { globMatch } from "../utils/glob.js";
|
|
7
7
|
import { validateLocalPath, validateRemotePath } from "../utils/validation.js";
|
|
8
8
|
import { isProtectedEntry } from "../utils/localguard.js";
|
|
@@ -135,7 +135,7 @@ export async function syncPlan(svc, a, walk = walkTree) {
|
|
|
135
135
|
const limit = Math.max(1, Math.min(a.limit ?? 200, 1000));
|
|
136
136
|
const patterns = a.ignore ?? DEFAULT_IGNORE;
|
|
137
137
|
const ignore = makeIgnore(patterns);
|
|
138
|
-
const w = await walk(svc, driveRoot, { exclude: patterns.filter((p) => !/[*?/]/.test(p)) });
|
|
138
|
+
const w = await walk(svc, driveRoot, { exclude: patterns.filter((p) => !/[*?/]/.test(p)), noDisk: true }); // a plan must come from a current walk
|
|
139
139
|
const scan = scanLocal(localRoot, ignore);
|
|
140
140
|
const diff = await diffTrees(scan.files, w, { compare, ignore, hashLocal: (rel) => sha1File(join(localRoot, ...rel.split("/"))) });
|
|
141
141
|
const cap = (xs) => xs.slice(0, limit);
|
|
@@ -151,6 +151,7 @@ export async function syncPlan(svc, a, walk = walkTree) {
|
|
|
151
151
|
listsTruncated: [diff.onlyLocal, diff.onlyDrive, diff.changed].some((l) => l.length > limit),
|
|
152
152
|
complete: w.complete && !scan.truncated,
|
|
153
153
|
driveWalkComplete: w.complete,
|
|
154
|
+
...staleFields(w),
|
|
154
155
|
skipped: cap(w.skipped),
|
|
155
156
|
local: { scanned: scan.scanned, symlinksSkipped: scan.symlinksSkipped, unreadable: scan.unreadable, protectedSkipped: scan.protectedSkipped, truncated: scan.truncated, maxEntries: MAX_LOCAL_ENTRIES },
|
|
156
157
|
note: "Plan only. onlyLocal = not on Drive, onlyDrive = not local; 'maybe changed' compares mtime (Drive claimed time) and can be a false positive." +
|
package/dist/services/walk.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { DriveNotAuthenticatedError } from "../utils/errors.js";
|
|
2
2
|
import { callContext } from "../utils/subprocess.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { indexEnabled, indexMaxAgeMs, readIndex, writeIndex, deleteIndex, INDEX_VERSION, MAX_ENTRIES } from "./walkIndex.js";
|
|
5
|
+
/** Output fields for results served from the persistent index while a refresh runs (empty otherwise). */
|
|
6
|
+
export const staleFields = (w) => w.stale ? { stale: true, ...(w.refreshing ? { refreshing: true } : {}) } : {};
|
|
3
7
|
export const DEFAULT_EXCLUDE = [".git", "node_modules"];
|
|
4
8
|
const DEFAULT_MAX_CALLS = 300;
|
|
5
9
|
const MAX_CONCURRENCY = 12; // the CLI's SQLite cache starts failing above this
|
|
@@ -58,16 +62,11 @@ function fromCache(entry, root, maxDepth, exclude, exact) {
|
|
|
58
62
|
callsMade: 0, fromCache: true, ageMs: Date.now() - entry.createdAt,
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
|
-
function
|
|
62
|
-
const now = Date.now(), ttl = ttlMs();
|
|
65
|
+
function pick(entries, root, maxDepth, key, excludeKey, exclude) {
|
|
63
66
|
let best;
|
|
64
|
-
for (const
|
|
65
|
-
if (now - e.createdAt >= ttl) {
|
|
66
|
-
cache.delete(k);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
67
|
+
for (const e of entries) {
|
|
69
68
|
if (e.key === key)
|
|
70
|
-
return
|
|
69
|
+
return { e, exact: true }; // same request, even if it was partial
|
|
71
70
|
// An ancestor's walk can answer a subfolder request if it saw everything and went deep enough.
|
|
72
71
|
if (e.excludeKey !== excludeKey || e.skipped.length > 0 || !isUnder(root, e.root))
|
|
73
72
|
continue;
|
|
@@ -83,32 +82,19 @@ function lookup(root, maxDepth, key, excludeKey, exclude) {
|
|
|
83
82
|
if (!best || e.root.length > best.root.length)
|
|
84
83
|
best = e;
|
|
85
84
|
}
|
|
86
|
-
return best &&
|
|
85
|
+
return best && { e: best, exact: false };
|
|
87
86
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(opts.concurrency ?? 8)));
|
|
100
|
-
const excludeList = [...new Set(opts.exclude ?? DEFAULT_EXCLUDE)].sort();
|
|
101
|
-
const exclude = new Set(excludeList);
|
|
102
|
-
const signal = opts.signal ?? callContext.getStore()?.signal;
|
|
103
|
-
if (signal?.aborted)
|
|
104
|
-
throw abortError(signal);
|
|
105
|
-
const excludeKey = excludeList.join("\0");
|
|
106
|
-
const key = [root, maxDepth ?? "", maxCalls, excludeKey].join("\0");
|
|
107
|
-
if (!opts.refresh) {
|
|
108
|
-
const hit = lookup(root, maxDepth, key, excludeKey, exclude);
|
|
109
|
-
if (hit)
|
|
110
|
-
return hit;
|
|
111
|
-
}
|
|
87
|
+
function lookup(root, maxDepth, key, excludeKey, exclude) {
|
|
88
|
+
const now = Date.now(), ttl = ttlMs();
|
|
89
|
+
for (const [k, e] of cache)
|
|
90
|
+
if (now - e.createdAt >= ttl)
|
|
91
|
+
cache.delete(k);
|
|
92
|
+
const hit = pick(cache.values(), root, maxDepth, key, excludeKey, exclude);
|
|
93
|
+
return hit && fromCache(hit.e, root, maxDepth, exclude, hit.exact);
|
|
94
|
+
}
|
|
95
|
+
/** The actual breadth-first walk (no caching). */
|
|
96
|
+
async function runWalk(svc, p, signal) {
|
|
97
|
+
const { root, maxDepth, maxCalls, concurrency, exclude } = p;
|
|
112
98
|
const nodes = [];
|
|
113
99
|
const skipped = [];
|
|
114
100
|
const queue = [{ path: root, depth: 0 }];
|
|
@@ -193,17 +179,287 @@ export async function walkTree(svc, rootPath, opts = {}) {
|
|
|
193
179
|
});
|
|
194
180
|
nodes.sort(byPath);
|
|
195
181
|
skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
182
|
+
return { nodes, skipped, callsMade, complete: skipped.length === 0 && !isDepthCut(nodes, depthOf(root), maxDepth, exclude) };
|
|
183
|
+
}
|
|
184
|
+
function remember(p, w) {
|
|
185
|
+
cache.delete(p.key);
|
|
186
|
+
cache.set(p.key, { root: p.root, key: p.key, excludeKey: p.excludeKey, maxDepth: p.maxDepth, createdAt: Date.now(), nodes: w.nodes, skipped: w.skipped, complete: w.complete });
|
|
199
187
|
while (cache.size > CACHE_ENTRIES)
|
|
200
188
|
cache.delete(cache.keys().next().value);
|
|
201
|
-
|
|
189
|
+
}
|
|
190
|
+
// ---- persistent index (opt-in, PROTON_DRIVE_INDEX=1) ------------------------
|
|
191
|
+
// Disk data is only served after one `list /my-files` proves it belongs to the
|
|
192
|
+
// current account, and is served stale-while-revalidate.
|
|
193
|
+
const ACCOUNT_ROOT = "/my-files";
|
|
194
|
+
/** undefined = not loaded yet, null = nothing usable on disk. */
|
|
195
|
+
let disk;
|
|
196
|
+
let diskVerified;
|
|
197
|
+
const VERIFY_TTL_MS = 10 * 60_000;
|
|
198
|
+
let shuttingDown = false;
|
|
199
|
+
const refreshFailedAt = new Map();
|
|
200
|
+
const cooldownMs = () => {
|
|
201
|
+
const v = Number(process.env.PROTON_DRIVE_INDEX_REFRESH_COOLDOWN_MS ?? 300_000);
|
|
202
|
+
return Number.isFinite(v) && v >= 0 ? v : 300_000;
|
|
203
|
+
};
|
|
204
|
+
let knownAccountKey;
|
|
205
|
+
let persistChain = Promise.resolve();
|
|
206
|
+
let invalidationSeq = 0;
|
|
207
|
+
const refreshes = new Map();
|
|
208
|
+
let exitHooked = false;
|
|
209
|
+
/** The parentUid shared by the first-level children of /my-files identifies the account's Drive root. */
|
|
210
|
+
function fingerprint(items) {
|
|
211
|
+
const ids = new Set(items.map((i) => i.parentUid));
|
|
212
|
+
const [only] = ids;
|
|
213
|
+
return ids.size === 1 && typeof only === "string" ? only : undefined;
|
|
214
|
+
}
|
|
215
|
+
function loadDisk() {
|
|
216
|
+
if (disk === undefined) {
|
|
217
|
+
const f = readIndex({ requireWritable: true });
|
|
218
|
+
disk = f ? { accountKey: f.accountKey, entries: f.entries } : null;
|
|
219
|
+
}
|
|
220
|
+
return disk;
|
|
221
|
+
}
|
|
222
|
+
function discardDisk() {
|
|
223
|
+
disk = null;
|
|
224
|
+
try {
|
|
225
|
+
deleteIndex();
|
|
226
|
+
}
|
|
227
|
+
catch { /* best effort */ }
|
|
228
|
+
}
|
|
229
|
+
/** Rejects with the caller's own abort reason without touching the shared promise. */
|
|
230
|
+
function raceAbort(p, signal) {
|
|
231
|
+
if (!signal)
|
|
232
|
+
return p;
|
|
233
|
+
if (signal.aborted)
|
|
234
|
+
return Promise.reject(abortError(signal));
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const onAbort = () => reject(abortError(signal));
|
|
237
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
238
|
+
p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* One `list /my-files` proves the saved data belongs to the live account. Only a
|
|
243
|
+
* successful comparison that differs deletes the file; any failure (network, rate
|
|
244
|
+
* limit, empty or unreadable listing) just serves nothing from disk this time and is
|
|
245
|
+
* not remembered. The check is shared by concurrent callers on its own signal, so one
|
|
246
|
+
* caller's abort cannot fail the others, and it is repeated after 10 minutes.
|
|
247
|
+
*/
|
|
248
|
+
async function verifyDisk(svc, signal) {
|
|
249
|
+
if (diskVerified && Date.now() - diskVerified.at > VERIFY_TTL_MS)
|
|
250
|
+
diskVerified = undefined;
|
|
251
|
+
if (!diskVerified) {
|
|
252
|
+
const ctl = new AbortController();
|
|
253
|
+
const promise = callContext.run({ signal: ctl.signal }, async () => {
|
|
254
|
+
try {
|
|
255
|
+
const current = fingerprint((await svc.listEntries(ACCOUNT_ROOT)).map((e) => e.item));
|
|
256
|
+
if (!current)
|
|
257
|
+
return "unknown";
|
|
258
|
+
knownAccountKey = current;
|
|
259
|
+
if (disk && disk.accountKey === current)
|
|
260
|
+
return "match";
|
|
261
|
+
discardDisk();
|
|
262
|
+
return "mismatch";
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return "unknown";
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
const rec = { at: Date.now(), ctl, promise };
|
|
269
|
+
diskVerified = rec;
|
|
270
|
+
void promise.then((v) => { if (v === "unknown" && diskVerified === rec)
|
|
271
|
+
diskVerified = undefined; });
|
|
272
|
+
}
|
|
273
|
+
return (await raceAbort(diskVerified.promise, signal)) === "match";
|
|
274
|
+
}
|
|
275
|
+
const toCacheEntry = (e) => ({
|
|
276
|
+
root: e.root, key: e.key, excludeKey: e.opts.exclude.join("\0"), maxDepth: e.opts.maxDepth,
|
|
277
|
+
createdAt: e.completedAt, nodes: e.nodes, skipped: [], complete: true,
|
|
278
|
+
});
|
|
279
|
+
function diskCandidates() {
|
|
280
|
+
const st = loadDisk();
|
|
281
|
+
const now = Date.now(), maxAge = indexMaxAgeMs();
|
|
282
|
+
return st ? st.entries.filter((e) => { const age = now - e.completedAt; return Number.isFinite(age) && age >= 0 && age <= maxAge; }).map(toCacheEntry) : [];
|
|
283
|
+
}
|
|
284
|
+
function persist(svc, p, w, startSeq) {
|
|
285
|
+
if (!indexEnabled() || !w.complete || !isUnder(p.root, ACCOUNT_ROOT))
|
|
286
|
+
return;
|
|
287
|
+
persistChain = persistChain.then(async () => {
|
|
288
|
+
try {
|
|
289
|
+
if (shuttingDown || invalidationSeq !== startSeq)
|
|
290
|
+
return; // a write happened mid-walk: do not save a possibly outdated tree
|
|
291
|
+
let acct = knownAccountKey ?? (p.root === ACCOUNT_ROOT ? fingerprint(w.nodes.filter((n) => depthOf(n.path) === 2)) : undefined);
|
|
292
|
+
if (!acct)
|
|
293
|
+
acct = fingerprint((await svc.listEntries(ACCOUNT_ROOT)).map((e) => e.item));
|
|
294
|
+
if (!acct || shuttingDown || invalidationSeq !== startSeq)
|
|
295
|
+
return;
|
|
296
|
+
knownAccountKey = acct;
|
|
297
|
+
const cur = loadDisk();
|
|
298
|
+
const base = cur && cur.accountKey === acct ? cur.entries : [];
|
|
299
|
+
const entry = { key: p.key, root: p.root, opts: { maxDepth: p.maxDepth, maxCalls: p.maxCalls, exclude: p.excludeList }, completedAt: Date.now(), nodes: w.nodes };
|
|
300
|
+
const entries = [entry, ...base.filter((e) => e.key !== p.key)].sort((a, b) => b.completedAt - a.completedAt).slice(0, MAX_ENTRIES);
|
|
301
|
+
const file = { version: INDEX_VERSION, accountKey: acct, savedAt: Date.now(), entries };
|
|
302
|
+
if (writeIndex(file) === "ok") {
|
|
303
|
+
disk = { accountKey: acct, entries };
|
|
304
|
+
diskVerified = { at: Date.now(), ctl: new AbortController(), promise: Promise.resolve("match") }; // saved under the live drive's own account key
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
logger.warn("Persistent walk index not updated:", err instanceof Error ? err.message : String(err));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Stops background index refreshes (process shutdown). */
|
|
313
|
+
export function abortBackgroundRefreshes() {
|
|
314
|
+
shuttingDown = true;
|
|
315
|
+
diskVerified?.ctl.abort(new Error("walk aborted"));
|
|
316
|
+
for (const r of refreshes.values())
|
|
317
|
+
r.controller.abort(new Error("walk aborted"));
|
|
318
|
+
}
|
|
319
|
+
/** True when a refresh is running (or was just started); false while a failed one cools down. */
|
|
320
|
+
function startRefresh(svc, p) {
|
|
321
|
+
if (refreshes.has(p.key))
|
|
322
|
+
return true;
|
|
323
|
+
const failed = refreshFailedAt.get(p.key);
|
|
324
|
+
if (failed !== undefined && Date.now() - failed < cooldownMs())
|
|
325
|
+
return false;
|
|
326
|
+
if (!exitHooked) {
|
|
327
|
+
exitHooked = true;
|
|
328
|
+
process.once("exit", abortBackgroundRefreshes);
|
|
329
|
+
}
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const rec = { controller, root: p.root, superseded: false, promise: Promise.resolve() };
|
|
332
|
+
const startSeq = invalidationSeq;
|
|
333
|
+
refreshes.set(p.key, rec);
|
|
334
|
+
// Own signal, not the triggering request's: that request has already returned.
|
|
335
|
+
rec.promise = callContext.run({ signal: controller.signal }, () => runWalk(svc, p, controller.signal))
|
|
336
|
+
.then((w) => {
|
|
337
|
+
if (rec.superseded)
|
|
338
|
+
return;
|
|
339
|
+
if (!w.complete) {
|
|
340
|
+
refreshFailedAt.set(p.key, Date.now());
|
|
341
|
+
return;
|
|
342
|
+
} // keep the stale entry rather than store a partial tree
|
|
343
|
+
refreshFailedAt.delete(p.key);
|
|
344
|
+
remember(p, w);
|
|
345
|
+
persist(svc, p, w, startSeq);
|
|
346
|
+
})
|
|
347
|
+
.catch((err) => {
|
|
348
|
+
if (controller.signal.aborted)
|
|
349
|
+
return;
|
|
350
|
+
refreshFailedAt.set(p.key, Date.now());
|
|
351
|
+
logger.warn("Background walk refresh failed:", err instanceof Error ? err.message : String(err));
|
|
352
|
+
})
|
|
353
|
+
.finally(() => { if (refreshes.get(p.key) === rec)
|
|
354
|
+
refreshes.delete(p.key); });
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
async function diskLookup(svc, p, signal) {
|
|
358
|
+
if (!indexEnabled() || indexMaxAgeMs() <= 0)
|
|
359
|
+
return undefined;
|
|
360
|
+
const find = () => pick(diskCandidates(), p.root, p.maxDepth, p.key, p.excludeKey, p.exclude);
|
|
361
|
+
if (!find())
|
|
362
|
+
return undefined;
|
|
363
|
+
if (!(await verifyDisk(svc, signal)))
|
|
364
|
+
return undefined;
|
|
365
|
+
const hit = find(); // the file may have been invalidated or discarded while we checked the account
|
|
366
|
+
if (!hit)
|
|
367
|
+
return undefined;
|
|
368
|
+
const res = fromCache(hit.e, p.root, p.maxDepth, p.exclude, hit.exact);
|
|
369
|
+
if (res.ageMs < ttlMs())
|
|
370
|
+
return { ...res, stale: true };
|
|
371
|
+
return { ...res, stale: true, ...(startRefresh(svc, p) ? { refreshing: true } : {}) };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Breadth-first walk of a Drive folder with bounded parallelism. A folder that
|
|
375
|
+
* still fails after one retry goes to `skipped` (its subtree is never dropped
|
|
376
|
+
* silently); maxCalls/maxDepth cuts set `complete: false`. Results are cached
|
|
377
|
+
* for PROTON_DRIVE_WALK_TTL_MS (default 5 min); a walk of a subfolder can be
|
|
378
|
+
* served from a cached walk of an ancestor. With PROTON_DRIVE_INDEX=1 complete
|
|
379
|
+
* walks are also saved to disk and served stale-while-revalidate in a new process.
|
|
380
|
+
*/
|
|
381
|
+
export async function walkTree(svc, rootPath, opts = {}) {
|
|
382
|
+
const root = normalizeRoot(rootPath);
|
|
383
|
+
const maxDepth = opts.maxDepth === undefined ? undefined : Math.max(1, Math.floor(opts.maxDepth));
|
|
384
|
+
const maxCalls = Math.max(1, Math.floor(opts.maxCalls ?? DEFAULT_MAX_CALLS));
|
|
385
|
+
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(opts.concurrency ?? 8)));
|
|
386
|
+
const excludeList = [...new Set(opts.exclude ?? DEFAULT_EXCLUDE)].sort();
|
|
387
|
+
const exclude = new Set(excludeList);
|
|
388
|
+
const signal = opts.signal ?? callContext.getStore()?.signal;
|
|
389
|
+
if (signal?.aborted)
|
|
390
|
+
throw abortError(signal);
|
|
391
|
+
const excludeKey = excludeList.join("\0");
|
|
392
|
+
const key = [root, maxDepth ?? "", maxCalls, excludeKey].join("\0");
|
|
393
|
+
const params = { root, maxDepth, maxCalls, concurrency, excludeList, exclude, key, excludeKey };
|
|
394
|
+
if (!opts.refresh) {
|
|
395
|
+
const hit = lookup(root, maxDepth, key, excludeKey, exclude);
|
|
396
|
+
if (hit)
|
|
397
|
+
return hit;
|
|
398
|
+
if (!opts.noDisk) {
|
|
399
|
+
const fromDisk = await diskLookup(svc, params, signal);
|
|
400
|
+
if (fromDisk)
|
|
401
|
+
return fromDisk;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const startSeq = invalidationSeq;
|
|
405
|
+
const w = await runWalk(svc, params, signal);
|
|
406
|
+
remember(params, w);
|
|
407
|
+
persist(svc, params, w, startSeq);
|
|
408
|
+
return { root, nodes: w.nodes.slice(), complete: w.complete, callsMade: w.callsMade, skipped: w.skipped.slice(), fromCache: false, ageMs: 0 };
|
|
202
409
|
}
|
|
203
410
|
/** Drop cached walks that contain `path` (call after the server itself writes there). */
|
|
204
411
|
export function invalidatePath(path) {
|
|
205
412
|
const p = normalizeRoot(path);
|
|
413
|
+
const related = (root) => isUnder(root, p) || isUnder(p, root);
|
|
414
|
+
invalidationSeq++;
|
|
206
415
|
for (const [k, e] of cache)
|
|
207
|
-
if (
|
|
416
|
+
if (related(e.root))
|
|
208
417
|
cache.delete(k);
|
|
418
|
+
for (const r of refreshes.values())
|
|
419
|
+
if (related(r.root)) {
|
|
420
|
+
r.superseded = true;
|
|
421
|
+
r.controller.abort(new Error("walk superseded"));
|
|
422
|
+
}
|
|
423
|
+
// Memory first: nothing related may be served from the loaded state, whatever happens to the file.
|
|
424
|
+
if (disk) {
|
|
425
|
+
const keepMem = disk.entries.filter((e) => !related(e.root));
|
|
426
|
+
disk = keepMem.length ? { accountKey: disk.accountKey, entries: keepMem } : null;
|
|
427
|
+
}
|
|
428
|
+
if (!indexEnabled())
|
|
429
|
+
return;
|
|
430
|
+
try {
|
|
431
|
+
const f = readIndex(); // re-read: another process may have saved since we loaded
|
|
432
|
+
if (!f)
|
|
433
|
+
return;
|
|
434
|
+
const keep = f.entries.filter((e) => !related(e.root));
|
|
435
|
+
if (keep.length === f.entries.length)
|
|
436
|
+
return;
|
|
437
|
+
// If the rewrite does not succeed, remove the file so no deleted/moved item can outlive this call.
|
|
438
|
+
if (keep.length === 0 || writeIndex({ ...f, savedAt: Date.now(), entries: keep }) !== "ok")
|
|
439
|
+
deleteIndex();
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
try {
|
|
443
|
+
deleteIndex();
|
|
444
|
+
}
|
|
445
|
+
catch { /* never fail a write because of the index */ }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Test hook: drop all in-process state (memory cache, loaded index, background refreshes). */
|
|
449
|
+
export function resetWalkCacheForTests() {
|
|
450
|
+
abortBackgroundRefreshes();
|
|
451
|
+
refreshes.clear();
|
|
452
|
+
cache.clear();
|
|
453
|
+
disk = undefined;
|
|
454
|
+
diskVerified = undefined;
|
|
455
|
+
knownAccountKey = undefined;
|
|
456
|
+
refreshFailedAt.clear();
|
|
457
|
+
shuttingDown = false;
|
|
458
|
+
}
|
|
459
|
+
/** Test hook: resolves when background refreshes and index writes have finished. */
|
|
460
|
+
export async function flushWalkIndexForTests() {
|
|
461
|
+
do {
|
|
462
|
+
await Promise.allSettled([...refreshes.values()].map((r) => r.promise));
|
|
463
|
+
await persistChain;
|
|
464
|
+
} while (refreshes.size > 0);
|
|
209
465
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { accessSync, chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* Opt-in on-disk copy of completed Drive walks (PROTON_DRIVE_INDEX=1). It holds
|
|
8
|
+
* file names, paths and hashes of an end-to-end-encrypted drive in PLAINTEXT, so
|
|
9
|
+
* it is off by default; the file is 0600 in a 0700 directory that this code created or that was already private to the user.
|
|
10
|
+
*/
|
|
11
|
+
export const INDEX_VERSION = 1;
|
|
12
|
+
export const MAX_ENTRIES = 8;
|
|
13
|
+
export const MAX_BYTES = 200 * 1024 * 1024;
|
|
14
|
+
const FILE_NAME = "walk-index.json";
|
|
15
|
+
export function indexEnabled() {
|
|
16
|
+
const v = (process.env.PROTON_DRIVE_INDEX ?? "").trim().toLowerCase();
|
|
17
|
+
return v === "1" || v === "true";
|
|
18
|
+
}
|
|
19
|
+
export function indexDir() {
|
|
20
|
+
const d = process.env.PROTON_DRIVE_INDEX_DIR;
|
|
21
|
+
if (d && d.trim())
|
|
22
|
+
return d;
|
|
23
|
+
if (platform() === "darwin")
|
|
24
|
+
return join(homedir(), "Library", "Caches", "proton-drive-mcp");
|
|
25
|
+
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "proton-drive-mcp");
|
|
26
|
+
}
|
|
27
|
+
export const indexPath = () => join(indexDir(), FILE_NAME);
|
|
28
|
+
/** Max age of disk data we will serve, ms (PROTON_DRIVE_INDEX_MAX_AGE_H, default 24 h; 0 disables use). */
|
|
29
|
+
export function indexMaxAgeMs() {
|
|
30
|
+
const h = Number(process.env.PROTON_DRIVE_INDEX_MAX_AGE_H ?? 24);
|
|
31
|
+
return (Number.isFinite(h) && h >= 0 ? h : 24) * 3_600_000;
|
|
32
|
+
}
|
|
33
|
+
const lstatOrNull = (p) => { try {
|
|
34
|
+
return lstatSync(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
} };
|
|
39
|
+
const FUTURE_SKEW_MS = 5 * 60_000;
|
|
40
|
+
const foreignOrLoose = (st) => (typeof process.getuid === "function" && st.uid !== process.getuid()) || (st.mode & 0o077) !== 0;
|
|
41
|
+
/** Reads and validates the index; anything wrong (missing, symlink, loose mode, foreign owner, oversize, corrupt, other version) is "no index". */
|
|
42
|
+
export function readIndex(o = {}) {
|
|
43
|
+
const p = indexPath();
|
|
44
|
+
const dst = lstatOrNull(indexDir());
|
|
45
|
+
if (!dst || !dst.isDirectory())
|
|
46
|
+
return undefined;
|
|
47
|
+
if (o.requireWritable) {
|
|
48
|
+
// Data we cannot keep current (a read-only directory can neither be rewritten nor cleared after our own
|
|
49
|
+
// writes) must not be served: a trashed or moved item would reappear in the next process.
|
|
50
|
+
try {
|
|
51
|
+
accessSync(indexDir(), constants.W_OK);
|
|
52
|
+
accessSync(p, constants.W_OK);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let fd;
|
|
59
|
+
try {
|
|
60
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW); // a symlink is never followed
|
|
61
|
+
const st = fstatSync(fd);
|
|
62
|
+
if (!st.isFile() || foreignOrLoose(st) || st.size > MAX_BYTES)
|
|
63
|
+
return undefined; // checked before reading
|
|
64
|
+
const j = JSON.parse(readFileSync(fd, "utf8"));
|
|
65
|
+
if (!j || j.version !== INDEX_VERSION || typeof j.accountKey !== "string" || !Array.isArray(j.entries))
|
|
66
|
+
return undefined;
|
|
67
|
+
const horizon = Date.now() + FUTURE_SKEW_MS;
|
|
68
|
+
const ok = j.entries.every((e) => e && typeof e.key === "string" && typeof e.root === "string" && Number.isFinite(e.completedAt) && e.completedAt <= horizon && Array.isArray(e.nodes) && e.opts && Array.isArray(e.opts.exclude));
|
|
69
|
+
return ok ? j : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (fd !== undefined) {
|
|
76
|
+
try {
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
catch { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let warnedSize = false;
|
|
84
|
+
const warnedDirs = new Set();
|
|
85
|
+
/** Atomic write. Never throws; the result says what happened. */
|
|
86
|
+
export function writeIndex(file, o = {}) {
|
|
87
|
+
if (!indexEnabled())
|
|
88
|
+
return "disabled";
|
|
89
|
+
const dir = indexDir();
|
|
90
|
+
let tmp;
|
|
91
|
+
try {
|
|
92
|
+
const trimmed = { ...file, entries: [...file.entries].sort((a, b) => b.completedAt - a.completedAt).slice(0, MAX_ENTRIES) };
|
|
93
|
+
const body = JSON.stringify(trimmed);
|
|
94
|
+
if (Buffer.byteLength(body) > (o.maxBytes ?? MAX_BYTES)) {
|
|
95
|
+
if (!warnedSize) {
|
|
96
|
+
warnedSize = true;
|
|
97
|
+
logger.warn("Persistent walk index not saved: it would exceed the size cap.");
|
|
98
|
+
}
|
|
99
|
+
return "too-large";
|
|
100
|
+
}
|
|
101
|
+
const existing = lstatOrNull(dir);
|
|
102
|
+
if (existing) {
|
|
103
|
+
// Never chmod or take over a directory this code did not create.
|
|
104
|
+
if (!existing.isDirectory() || foreignOrLoose(existing)) {
|
|
105
|
+
if (!warnedDirs.has(dir)) {
|
|
106
|
+
warnedDirs.add(dir);
|
|
107
|
+
logger.warn(`Persistent walk index not saved: ${dir} must be a real directory owned by you with no group/other access (mode 0700). Use a dedicated directory.`);
|
|
108
|
+
}
|
|
109
|
+
return "refused";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
114
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
115
|
+
chmodSync(dir, 0o700); // umask-proof; safe because we just created it
|
|
116
|
+
const made = lstatSync(dir);
|
|
117
|
+
if (!made.isDirectory() || foreignOrLoose(made))
|
|
118
|
+
return "refused";
|
|
119
|
+
}
|
|
120
|
+
const target = join(dir, FILE_NAME);
|
|
121
|
+
const tst = lstatOrNull(target);
|
|
122
|
+
if (tst && !tst.isFile())
|
|
123
|
+
return "refused"; // symlink or directory in the way
|
|
124
|
+
tmp = join(dir, `${FILE_NAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
125
|
+
const fd = openSync(tmp, "wx", 0o600); // O_EXCL: never follows a pre-planted link
|
|
126
|
+
try {
|
|
127
|
+
writeSync(fd, body);
|
|
128
|
+
fsyncSync(fd);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
chmodSync(tmp, 0o600);
|
|
134
|
+
renameSync(tmp, target);
|
|
135
|
+
tmp = undefined;
|
|
136
|
+
const fin = lstatSync(target);
|
|
137
|
+
if (!fin.isFile() || (fin.mode & 0o077) !== 0) {
|
|
138
|
+
try {
|
|
139
|
+
unlinkSync(target);
|
|
140
|
+
}
|
|
141
|
+
catch { /* best effort */ }
|
|
142
|
+
return "refused";
|
|
143
|
+
}
|
|
144
|
+
return "ok";
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
logger.warn("Persistent walk index write failed:", err instanceof Error ? err.message : String(err));
|
|
148
|
+
return "error";
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
if (tmp) {
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(tmp);
|
|
154
|
+
}
|
|
155
|
+
catch { /* already gone */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Deletes the index file (a symlink is removed itself, never followed). */
|
|
160
|
+
export function deleteIndex() {
|
|
161
|
+
const path = indexPath();
|
|
162
|
+
const st = lstatOrNull(path);
|
|
163
|
+
if (!st)
|
|
164
|
+
return { path, deleted: false, bytes: 0 };
|
|
165
|
+
try {
|
|
166
|
+
unlinkSync(path);
|
|
167
|
+
return { path, deleted: true, bytes: st.size };
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return { path, deleted: false, bytes: 0 };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function indexStatus() {
|
|
174
|
+
const path = indexPath();
|
|
175
|
+
const st = lstatOrNull(path);
|
|
176
|
+
const base = { enabled: indexEnabled(), path, maxAgeHours: indexMaxAgeMs() / 3_600_000 };
|
|
177
|
+
if (!st)
|
|
178
|
+
return { ...base, exists: false };
|
|
179
|
+
const idx = readIndex();
|
|
180
|
+
return {
|
|
181
|
+
...base, exists: true, bytes: st.size, valid: !!idx,
|
|
182
|
+
...(idx ? {
|
|
183
|
+
savedAt: new Date(idx.savedAt).toISOString(), ageMs: Date.now() - idx.savedAt,
|
|
184
|
+
entries: idx.entries.map((e) => ({ root: e.root, nodes: e.nodes.length, completedAt: new Date(e.completedAt).toISOString() })),
|
|
185
|
+
} : { entries: [] }),
|
|
186
|
+
};
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proton-drive-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "MCP server and CLI that gives Claude full access to Proton Drive — upload, download, share, and manage your end-to-end encrypted files without leaving the conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dev": "tsc --watch",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"cli": "npm run build && node dist/cli.js",
|
|
21
|
-
"test": "npm run build && node --test --test-concurrency=4 test/*.test.mjs",
|
|
21
|
+
"test": "npm run build && node --import ./test/helpers/no-index.mjs --test --test-concurrency=4 test/*.test.mjs",
|
|
22
22
|
"lint": "tsc --noEmit",
|
|
23
23
|
"clean": "rm -rf dist",
|
|
24
24
|
"ci": "npm run lint && npm test",
|