Skip to content

fix(upgrade): Resolve symlinks before self-copy guard in installBinary#1046

Merged
BYK merged 5 commits into
mainfrom
fix/install-binary-symlink-self-copy
Jun 1, 2026
Merged

fix(upgrade): Resolve symlinks before self-copy guard in installBinary#1046
BYK merged 5 commits into
mainfrom
fix/install-binary-symlink-self-copy

Conversation

@sergical
Copy link
Copy Markdown
Member

@sergical sergical commented Jun 1, 2026

installBinary deletes the source file it is about to copy when the install directory is reached through a symlink, crashing the upgrade with ENOENT. The self-copy guard compared paths with resolve(), which makes paths absolute but does not follow symlinks. On macOS /tmp is a symlink to /private/tmp, so a download at /private/tmp/.../sentry.download (the canonicalized process.execPath) and the temp target /tmp/.../sentry.download looked different despite being the same file — the guard misfired, unlink() removed the source, and copyFile() then failed.

This surfaces during sentry cli upgrade when SENTRY_INSTALL_DIR points through a symlink; a normal ~/.local/bin install is unaffected, which is why it was intermittent.

Symlink-aware comparison

The guard now compares realpathSync-canonicalized paths, falling back to resolve() when the path does not exist yet (the normal case where the temp file has not been created).

Before: resolve(/private/tmp/.../sentry.download) !== resolve(/tmp/.../sentry.download)  // true -> deletes source
After:  realpath(/private/tmp/.../sentry.download) === realpath(/tmp/.../sentry.download) // true -> skips copy

Adds a regression test that installs through a symlinked dir and fails (ENOENT) without the fix.

When the install dir is reached through a symlink (e.g. macOS /tmp ->
/private/tmp), the source path (canonicalized by process.execPath) and the
.download temp path point at the same file but differ as strings. The
resolve()-based guard then failed to detect this, unlinked the source, and
copyFile() crashed with ENOENT. Compare realpath-canonicalized paths instead,
falling back to resolve() when the path does not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-1046/

Built to branch gh-pages at 2026-06-01 20:52 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@sergical sergical marked this pull request as ready for review June 1, 2026 17:32
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Codecov Results 📊

✅ Patch coverage is 90.00%. Project has 4292 uncovered lines.
✅ Project coverage is 82.05%. Comparing base (base) to head (head).

Files with missing lines (1)
File Patch % Lines
src/lib/binary.ts 90.00% ⚠️ 1 Missing and 1 partials
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    81.98%    82.05%    +0.07%
==========================================
  Files          329       329         —
  Lines        23883     23917       +34
  Branches     15603     15634       +31
==========================================
+ Hits         19581     19625       +44
- Misses        4302      4292       -10
- Partials      1651      1649        -2

Generated by Codecov Action

Copy link
Copy Markdown
Member

@MathurAditya724 MathurAditya724 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff — the fix is correct and well-scoped.

What I verified:

  • realpathSync resolves through symlinks (unlike resolve() which only makes paths absolute), so the /tmp/private/tmp macOS case is correctly handled.
  • The try/catch fallback to resolve() is necessary because realpathSync throws ENOENT on non-existent paths, which is the normal case when tempPath hasn't been created yet.
  • The new test correctly reproduces the bug scenario: canonical sourcePath vs symlinked installDir pointing to the same real directory.
  • All 49 tests in binary.test.ts pass. CI is green.

One minor note (non-blocking): The catch block in the canonical helper at src/lib/binary.ts:458 is silent (no log.debug). The repo's AGENTS.md prohibits silent catch blocks in src/, though the existing file already has several (lines 70, 280, 285, 416, 467). Since this is a pre-existing pattern in this file and the fallback behavior is clearly documented in the comment block above, this isn't worth blocking on — but a log.debug("realpathSync failed, falling back to resolve()", error) would be consistent with the stated policy if you want to tighten it up.

No findings.

Per AGENTS.md, catch blocks in src/ must not silently swallow errors. Guard
the expected "path does not exist yet" case with existsSync (no log, since it
fires on every normal install) and log.debug() only the genuinely unexpected
realpathSync failures in the catch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sergical
Copy link
Copy Markdown
Member Author

sergical commented Jun 1, 2026

Thanks for the careful review! Addressed the silent-catch note in 7327753.

One nuance worth calling out: I applied it slightly differently than the literal suggestion. Dropping log.debug("realpathSync failed, falling back to resolve()", error) straight into the catch would actually fire on every normal install — realpathSync(tempPath) throwing ENOENT is the expected path when tempPath hasn't been created yet, so it isn't really a failure. To avoid crying wolf on normal operation, I short-circuit that expected case with existsSync (no log) and reserve the log.debug for the catch, which now only triggers on genuinely unexpected failures (e.g. permissions):

const canonical = (p: string): string => {
  if (!existsSync(p)) {
    return resolve(p);
  }
  try {
    return realpathSync(p);
  } catch (error) {
    logger.debug("realpathSync failed, falling back to resolve()", error);
    return resolve(p);
  }
};

Left the pre-existing silent catches (lines 70, 280, 285, 416, 467) out of scope to keep this PR focused — happy to do a separate cleanup pass on them if that's useful.

Adds a mocked test exercising the catch branch where realpathSync throws on an
existing path (permission error or TOCTOU race), asserting installBinary falls
back to resolve() and logs. Kept in a sibling .mocked.test.ts so the node:fs
mock does not leak into binary.test.ts. Brings patch coverage above target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sergical sergical requested a review from BYK June 1, 2026 18:07
- Replace realpathSync with async realpath from node:fs/promises for
  consistency with the surrounding async function
- Replace existsSync guard with try/catch ENOENT pattern to avoid
  TOCTOU race condition
- Trim excessive comments on canonical helper and mocked test header
- Fix silent catch block in temp file cleanup (add logger.debug)
- Update mocked test to mock node:fs/promises realpath instead of
  node:fs realpathSync
Comment thread src/lib/binary.ts Outdated
Address Seer review: ENOENT is the expected case (no leftover temp
file from a previous interrupted operation), so skip the debug log
for it.
Copy link
Copy Markdown
Member

@BYK BYK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in af2191b. ENOENT is now silently ignored (expected case: no leftover temp file), while unexpected errors still log via logger.debug.

@BYK BYK merged commit 5abcacd into main Jun 1, 2026
29 checks passed
@BYK BYK deleted the fix/install-binary-symlink-self-copy branch June 1, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants