proton-drive-mcp 1.3.2 → 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 CHANGED
@@ -1,5 +1,21 @@
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
+
14
+ ## 1.3.3 — 2026-10-01
15
+
16
+ ### Fixed
17
+ - `drive_find_duplicates` with `verify`: on a real 1,125-file drive about half of the verification downloads failed with `database is locked` (the CLI's SQLite cache, hit by 4 parallel downloads) and left groups unverified. Verification now retries that error in a fresh temp dir (max 4 attempts), runs 2 downloads at a time by default, and failure notes are a single line instead of a SQLite dump. Re-run on the same drive: 30 of 30 groups verified, no lock errors.
18
+
3
19
  ## 1.3.2 — 2026-10-01
4
20
 
5
21
  ### Changed
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`. You still need the official `proton-drive` CLI installed and `proton-drive auth login` done once.
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.0 KB (46 tools), `core` 16.8 KB (18 tools). Set `PROTON_DRIVE_TOOL_TIER=core` to load only:
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) {
@@ -163,14 +163,14 @@ export async function verifyGroups(groups, hash, o) {
163
163
  totalMembers += g.members.length;
164
164
  const digests = new Map();
165
165
  const failed = [];
166
- await pool(g.members, o.concurrency ?? 4, async (m) => {
166
+ await pool(g.members, o.concurrency ?? 2, async (m) => {
167
167
  if (o.signal?.aborted)
168
168
  return;
169
169
  try {
170
170
  digests.set(m, await hash(m));
171
171
  }
172
172
  catch (e) {
173
- failed.push(`${m.path}: ${e instanceof Error ? e.message : String(e)}`);
173
+ failed.push(`${m.path}: ${oneLine(e instanceof Error ? e.message : String(e))}`);
174
174
  }
175
175
  });
176
176
  stop(); // the hasher's own finally already removed any temp dir
@@ -187,9 +187,16 @@ export async function verifyGroups(groups, hash, o) {
187
187
  }
188
188
  return out.filter((g) => g.members.length > 1).sort((a, b) => wasted(b) - wasted(a) || (a.members[0].path < b.members[0].path ? -1 : 1));
189
189
  }
190
- /** Downloads one file into its own private temp dir, sha256s it, and always removes the dir. */
190
+ const LOCKED_RE = /database is locked|SQLITE_BUSY/i;
191
+ const oneLine = (m) => (m.split("\n")[0] ?? m).slice(0, 160);
192
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
193
+ /**
194
+ * Downloads one file into its own private temp dir, sha256s it, and always removes the dir.
195
+ * The CLI's SQLite cache can report "database is locked" at startup when several downloads run
196
+ * at once; nothing has been transferred then, so a fresh attempt is safe (max 4 tries).
197
+ */
191
198
  export function makeDriveHasher(drive) {
192
- return async (node) => {
199
+ const once = async (node) => {
193
200
  const dir = await mkdtemp(join(tmpdir(), "pdmcp-dup-"));
194
201
  try {
195
202
  validateLocalPath(dir); // same local-path rules as drive_download
@@ -208,6 +215,20 @@ export function makeDriveHasher(drive) {
208
215
  await rm(dir, { recursive: true, force: true });
209
216
  }
210
217
  };
218
+ return async (node) => {
219
+ const base = Number(process.env["PROTON_DRIVE_RETRY_BASE_MS"] ?? 250);
220
+ for (let attempt = 1;; attempt++) {
221
+ try {
222
+ return await once(node);
223
+ }
224
+ catch (e) {
225
+ const msg = e instanceof Error ? e.message : String(e);
226
+ if (attempt >= 4 || !LOCKED_RE.test(msg))
227
+ throw e;
228
+ await sleep(base * attempt + Math.random() * base);
229
+ }
230
+ }
231
+ };
211
232
  }
212
233
  function reportGroup(g) {
213
234
  const keeper = keeperOf(g.members);
@@ -224,7 +245,7 @@ export async function driveFindDuplicates(drive, o, deps = {}) {
224
245
  if (o.maxVerifyTotalBytes !== undefined && (!Number.isInteger(o.maxVerifyTotalBytes) || o.maxVerifyTotalBytes < 1 || o.maxVerifyTotalBytes > MAX_VERIFY_TOTAL_BYTES)) {
225
246
  throw new Error(`maxVerifyTotalBytes must be an integer between 1 and ${MAX_VERIFY_TOTAL_BYTES}`);
226
247
  }
227
- 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
228
249
  const limit = o.limit ?? 20;
229
250
  let groups = findDuplicateCandidates(walk.nodes, o.minSize ?? 1024);
230
251
  let verified = false;
@@ -271,7 +292,7 @@ export function auditShareStatus(path, s) {
271
292
  return { path, publicLink, invitees, pending, flags };
272
293
  }
273
294
  export async function driveSharingAudit(drive, o, deps = {}) {
274
- 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
275
296
  const status = deps.status ?? ((p) => drive.shareStatus(p));
276
297
  const shared = walk.nodes.filter((n) => (n.isShared || n.isSharedByUrl) && parentOf(n.path) !== "");
277
298
  const targets = shared.slice(0, SHARE_AUDIT_CAP);
@@ -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
  }
@@ -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." +
@@ -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 lookup(root, maxDepth, key, excludeKey, exclude) {
62
- const now = Date.now(), ttl = ttlMs();
65
+ function pick(entries, root, maxDepth, key, excludeKey, exclude) {
63
66
  let best;
64
- for (const [k, e] of cache) {
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 fromCache(e, root, maxDepth, exclude, true); // same request, even if it was partial
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 && fromCache(best, root, maxDepth, exclude, false);
85
+ return best && { e: best, exact: false };
87
86
  }
88
- /**
89
- * Breadth-first walk of a Drive folder with bounded parallelism. A folder that
90
- * still fails after one retry goes to `skipped` (its subtree is never dropped
91
- * silently); maxCalls/maxDepth cuts set `complete: false`. Results are cached
92
- * for PROTON_DRIVE_WALK_TTL_MS (default 5 min); a walk of a subfolder can be
93
- * served from a cached walk of an ancestor.
94
- */
95
- export async function walkTree(svc, rootPath, opts = {}) {
96
- const root = normalizeRoot(rootPath);
97
- const maxDepth = opts.maxDepth === undefined ? undefined : Math.max(1, Math.floor(opts.maxDepth));
98
- const maxCalls = Math.max(1, Math.floor(opts.maxCalls ?? DEFAULT_MAX_CALLS));
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
- const complete = skipped.length === 0 && !isDepthCut(nodes, depthOf(root), maxDepth, exclude);
197
- cache.delete(key);
198
- cache.set(key, { root, key, excludeKey, maxDepth, createdAt: Date.now(), nodes, skipped, complete });
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
- return { root, nodes: nodes.slice(), complete, callsMade, skipped: skipped.slice(), fromCache: false, ageMs: 0 };
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 (isUnder(e.root, p) || isUnder(p, e.root))
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.2",
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",