fix(workspaces): reject existing non-symlink in fixSymlinkMissing#686
Conversation
📝 WalkthroughWalkthroughEnforce stricter symlink creation in ChangesStricter Symlink Fix Logic
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/workspaces/doctor_fix.go (2)
355-369: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winMerge the two
os.Lstatcalls to avoid redundant syscalls and TOCTOU window.
os.Lstat(symlinkPath)is called twice (lines 355 and 366) withos.Stat(expectedTarget)interleaved between them. The result from the first call is fully sufficient to determine both the idempotency and the non-symlink guard, but Go'sif-initialiser scoping forces the redeclaration. Hoisting Lstat before both blocks eliminates the extra syscall and the TOCTOU window between the two checks.♻️ Proposed refactor
func fixSymlinkMissing() error { _, symlinkPath, expectedTarget, err := symlinkPaths() if err != nil { return err } - // If it already exists and points at the right place, nothing to do. - if info, lerr := os.Lstat(symlinkPath); lerr == nil && info.Mode()&os.ModeSymlink != 0 { - if cur, rerr := os.Readlink(symlinkPath); rerr == nil && - filepath.Clean(cur) == filepath.Clean(expectedTarget) { - return nil - } + info, lerr := os.Lstat(symlinkPath) + if lerr == nil { + if info.Mode()&os.ModeSymlink == 0 { + // Regular file or directory — partial-migration case, refuse to clobber. + return fmt.Errorf("%w: ~/.wuphf is a regular path, not a symlink — see docs/multi-workspace.md", + ErrManualFixRequired) + } + // It's a symlink; check whether it already points at the right place. + if cur, rerr := os.Readlink(symlinkPath); rerr == nil && + filepath.Clean(cur) == filepath.Clean(expectedTarget) { + return nil + } } if _, err := os.Stat(expectedTarget); err != nil { return fmt.Errorf("symlink:missing: target %s does not exist: %w", expectedTarget, err) } - // If ~/.wuphf already exists as a regular file or directory, that's the - // partial-migration case — refuse to clobber user data (mirrors fixSymlinkWrong). - if info, lerr := os.Lstat(symlinkPath); lerr == nil && info.Mode()&os.ModeSymlink == 0 { - return fmt.Errorf("%w: ~/.wuphf is a regular path, not a symlink — see docs/multi-workspace.md", - ErrManualFixRequired) - } if err := os.Symlink(expectedTarget, symlinkPath); err != nil { return fmt.Errorf("symlink:missing: create %s → %s: %w", symlinkPath, expectedTarget, err) } return nil }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/workspaces/doctor_fix.go` around lines 355 - 369, Hoist the initial os.Lstat(symlinkPath) call and reuse its result instead of calling Lstat twice: call os.Lstat once into variables (e.g. info, lerr) before the idempotency and non-symlink checks, then use info.Mode()&os.ModeSymlink, os.Readlink(symlinkPath) and filepath.Clean comparisons with expectedTarget to detect an already-correct symlink, and only call os.Stat(expectedTarget) when Lstat indicates no valid symlink; keep the existing error returns and the ErrManualFixRequired path but remove the second os.Lstat to eliminate the redundant syscall and TOCTOU window.
399-405:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
fixSymlinkWrongstill swallowsos.ErrExist, leaving the symlink uncreated while returningnil.This PR removes the
!errors.Is(err, os.ErrExist)guard fromfixSymlinkMissing, but line 402 infixSymlinkWrongretains it:if err := os.Symlink(expectedTarget, symlinkPath); err != nil && !errors.Is(err, os.ErrExist) {After
os.Removesucceeds at line 390 (or whenlerr != nilat line 384 — path absent), a concurrent actor can create a path atsymlinkPathbeforeos.Symlinkruns. Ifos.Symlinkthen returnsEEXIST, the guard swallows the error and the function returnsnil— signalling success even though the intended symlink was never created. The PR's stated rationale applies equally here.🐛 Proposed fix
- if err := os.Symlink(expectedTarget, symlinkPath); err != nil && !errors.Is(err, os.ErrExist) { - return fmt.Errorf("symlink:wrong: recreate %s → %s: %w", symlinkPath, expectedTarget, err) - } + if err := os.Symlink(expectedTarget, symlinkPath); err != nil { + return fmt.Errorf("symlink:wrong: recreate %s → %s: %w", symlinkPath, expectedTarget, err) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/workspaces/doctor_fix.go` around lines 399 - 405, In fixSymlinkWrong, stop swallowing os.ErrExist: remove the "&& !errors.Is(err, os.ErrExist)" guard in the os.Symlink call (the check in the block that currently reads if err := os.Symlink(...); err != nil && !errors.Is(err, os.ErrExist) { ... }) so that any error from os.Symlink (including EEXIST) is returned to the caller; keep the prior existence check of expectedTarget and otherwise return the wrapped error from os.Symlink so callers see failures instead of receiving nil when the symlink was not actually created.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/workspaces/doctor_fix.go`:
- Around line 355-369: Hoist the initial os.Lstat(symlinkPath) call and reuse
its result instead of calling Lstat twice: call os.Lstat once into variables
(e.g. info, lerr) before the idempotency and non-symlink checks, then use
info.Mode()&os.ModeSymlink, os.Readlink(symlinkPath) and filepath.Clean
comparisons with expectedTarget to detect an already-correct symlink, and only
call os.Stat(expectedTarget) when Lstat indicates no valid symlink; keep the
existing error returns and the ErrManualFixRequired path but remove the second
os.Lstat to eliminate the redundant syscall and TOCTOU window.
- Around line 399-405: In fixSymlinkWrong, stop swallowing os.ErrExist: remove
the "&& !errors.Is(err, os.ErrExist)" guard in the os.Symlink call (the check in
the block that currently reads if err := os.Symlink(...); err != nil &&
!errors.Is(err, os.ErrExist) { ... }) so that any error from os.Symlink
(including EEXIST) is returned to the caller; keep the prior existence check of
expectedTarget and otherwise return the wrapped error from os.Symlink so callers
see failures instead of receiving nil when the symlink was not actually created.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 598f3f05-4363-4d50-a44e-08ab2a2e9862
📒 Files selected for processing (1)
internal/workspaces/doctor_fix.go
Hoist os.Lstat into a single call before both the non-symlink guard and the idempotency check, eliminating the redundant syscall and TOCTOU window. Also remove the os.ErrExist swallow in fixSymlinkWrong so that a concurrent EEXIST from os.Symlink surfaces as an error instead of silently returning nil with no symlink created.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
najmuzzaman-mohammad
left a comment
There was a problem hiding this comment.
Addresses all CodeRabbit feedback: merged redundant Lstat calls in fixSymlinkMissing, removed ErrExist swallow in fixSymlinkWrong. All symlink tests pass. LGTM.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/workspaces/doctor_fix.go (1)
354-372:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
fixSymlinkMissinghas two unhandled paths that produce confusing errors after the EEXIST swallow was removed.Issue A — wrong-symlink fallthrough (Case C):
When~/.wuphfalready exists as a symlink but points to the wrong target (oros.Readlinkfails for any reason), none of the early-return branches insideif lerr == nil { … }fire, so execution falls through toos.Symlink. Because the path exists,os.Symlinkalways returnsEEXIST, and the caller receives"symlink:missing: create ~/.wuphf → …: symlink: file exists"— an error message that gives no actionable guidance. The previous EEXIST swallow silently returnednilhere (which is the bug the PR fixes), but exposing it now reveals the underlying fallthrough rather than fixing it.A concrete trigger: Doctor reports
symlink:missingfor a symlink whose target has been deleted.os.Lstatfinds the dangling symlink,os.Readlinkreturns the stale (wrong) path, the idempotency check fails, and execution falls straight intoos.Symlink → EEXIST.Issue B — non-
ErrNotExistLstat error swallowed:
Ifos.Lstatfails for a reason other than the path not existing (e.g., a permission error),lerris non-nil but theif lerr == nilguard simply skips the entire block. Execution proceeds toos.Stat(expectedTarget)and thenos.Symlink, burying the original error under a secondary, unrelated failure. The rest of the file handles this correctly (fixOrphanTreeRegisterlines 126–132,fixOrphanTreeDeletelines 220–225).🐛 Proposed fix
info, lerr := os.Lstat(symlinkPath) if lerr == nil { if info.Mode()&os.ModeSymlink == 0 { // Regular file or directory — partial-migration case, refuse to clobber. return fmt.Errorf("%w: ~/.wuphf is a regular path, not a symlink — see docs/multi-workspace.md", ErrManualFixRequired) } // It's a symlink; check whether it already points at the right place. if cur, rerr := os.Readlink(symlinkPath); rerr == nil && filepath.Clean(cur) == filepath.Clean(expectedTarget) { return nil } + // Symlink exists but points elsewhere (or is unreadable) — that is the + // symlink:wrong case. Return a clear error so the caller/user knows to + // dispatch the right fix instead of seeing a confusing EEXIST below. + return fmt.Errorf("%w: ~/.wuphf symlink exists but does not point to the expected target — re-run doctor or use the symlink:wrong fix", + ErrManualFixRequired) + } else if !errors.Is(lerr, os.ErrNotExist) { + return fmt.Errorf("symlink:missing: stat %s: %w", symlinkPath, lerr) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/workspaces/doctor_fix.go` around lines 354 - 372, fixSymlinkMissing currently falls through to os.Symlink when a symlink exists but points to the wrong target (or when os.Readlink fails) and also swallows non-ErrNotExist lstat errors; change it so that if os.Lstat returns a non-nil error that is not os.IsNotExist(err) you return/wrap that error immediately, and if lstat succeeds and info.Mode()&os.ModeSymlink != 0 then handle two cases explicitly: if os.Readlink(symlinkPath) returns an error, return/wrap that error (don’t continue), and if Readlink returns a path different from expectedTarget, first remove the existing symlink (os.Remove(symlinkPath)) and then create the new symlink to expectedTarget (or return a clear, actionable error if removal fails); update error messages from the os.Symlink failure to be more specific (mention existing wrong symlink or inability to remove it) so callers get actionable guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/workspaces/doctor_fix.go`:
- Around line 354-372: fixSymlinkMissing currently falls through to os.Symlink
when a symlink exists but points to the wrong target (or when os.Readlink fails)
and also swallows non-ErrNotExist lstat errors; change it so that if os.Lstat
returns a non-nil error that is not os.IsNotExist(err) you return/wrap that
error immediately, and if lstat succeeds and info.Mode()&os.ModeSymlink != 0
then handle two cases explicitly: if os.Readlink(symlinkPath) returns an error,
return/wrap that error (don’t continue), and if Readlink returns a path
different from expectedTarget, first remove the existing symlink
(os.Remove(symlinkPath)) and then create the new symlink to expectedTarget (or
return a clear, actionable error if removal fails); update error messages from
the os.Symlink failure to be more specific (mention existing wrong symlink or
inability to remove it) so callers get actionable guidance.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 61abfa29-a999-4270-87fa-1cf2d171940c
📒 Files selected for processing (1)
internal/workspaces/doctor_fix.go
Summary
fixSymlinkMissingininternal/workspaces/doctor_fix.go:361swallowedos.ErrExistfromos.Symlink, so when~/.wuphfalready existed as a regular file or directory the function returned nil without creating the symlink. That masked the partial-migration case thatfixSymlinkWrongexplicitly refuses to clobber, leaving doctor reporting success on a still-broken workspace.Fix
Lstat
~/.wuphffirst and, if it exists as a non-symlink, returnErrManualFixRequiredpointing atdocs/multi-workspace.md. This is the same asfixSymlinkWrong. Theos.Symlinkcall no longer ignoresos.ErrExist, so any remaining create failure surfaces as a real error.Tests
Manually verified by pre-creating
~/.wuphfas a directory and as a regular file, then running doctor: both now fail with the manual-fix error instead of silently succeeding. The clean-slate path (no~/.wuphf) still creates the symlink as before.Summary by CodeRabbit