(file, id)
| 367 | // (or generate one if `id` is null). Returns the id that ended up on |
| 368 | // disk, or null on any unrecoverable error. EEXIST races re-read. |
| 369 | function _writeWorkspaceIdToFs(file, id) { |
| 370 | const dir = path.dirname(file); |
| 371 | try { |
| 372 | // Refuse to write if `.evolver` is a symlink. mkdirSync({recursive:true}) |
| 373 | // happily traverses an existing symlinked directory and the subsequent |
| 374 | // open() lands the secret file in the attacker-controlled target — |
| 375 | // O_NOFOLLOW only guards the FINAL path component, not intermediate |
| 376 | // directories. The pre-refactor monolithic getWorkspaceId() returned |
| 377 | // null on a symlinked dir before reaching the write; preserve that |
| 378 | // here (Bugbot PR #121 round-1 HIGH; original guard PR #109 round-2 HIGH). |
| 379 | const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false }); |
| 380 | if (dirStat && dirStat.isSymbolicLink()) return null; |
| 381 | fs.mkdirSync(dir, { recursive: true }); |
| 382 | const payload = id || require('crypto').randomBytes(16).toString('hex'); |
| 383 | // Atomic create-and-fail-if-exists so we never overwrite an |
| 384 | // attacker-pre-placed file (TOCTOU between lstat and writeFileSync |
| 385 | // could otherwise race a symlink in). O_NOFOLLOW also refuses to |
| 386 | // follow a symlink that appears between the lstat and open. Both |
| 387 | // flags exist on Linux/macOS; on Windows O_NOFOLLOW is silently |
| 388 | // ignored, but Windows has no symlink-by-default risk. |
| 389 | // NOTE(windows): mode 0o600 passed to openSync is silently ignored on |
| 390 | // Windows. The workspace-id file will NOT be restricted to owner-read-only. |
| 391 | // Only Windows user-profile directory ACLs provide isolation. To get |
| 392 | // proper per-user encryption on Windows, install @napi-rs/keyring — the |
| 393 | // keychain path above will store the id in Credential Manager (DPAPI) |
| 394 | // instead of this plaintext file. |
| 395 | const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | |
| 396 | (fs.constants.O_NOFOLLOW || 0); |
| 397 | let fd; |
| 398 | try { |
| 399 | fd = fs.openSync(file, flags, 0o600); |
| 400 | } catch (e) { |
| 401 | if (e && e.code === 'EEXIST') { |
| 402 | // Another process beat us — re-read with the same symlink guards. |
| 403 | return _readWorkspaceIdFromFs(file); |
| 404 | } |
| 405 | // ELOOP / EMLINK from O_NOFOLLOW hitting a symlink — refuse. |
| 406 | return null; |
| 407 | } |
| 408 | try { |
| 409 | fs.writeSync(fd, payload + '\n', 0, 'utf8'); |
| 410 | } finally { |
| 411 | fs.closeSync(fd); |
| 412 | } |
| 413 | // Best-effort chmod: silently ignored on Windows (no equivalent ACL API). |
| 414 | try { fs.chmodSync(file, 0o600); } catch { /* best-effort; no-op on Windows */ } |
| 415 | return payload; |
| 416 | } catch { |
| 417 | return null; |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | function getWorkspaceId() { |
| 422 | if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID); |
no test coverage detected