Skip to content

fix(workspaces): reject existing non-symlink in fixSymlinkMissing#686

Merged
najmuzzaman-mohammad merged 6 commits into
nex-crm:mainfrom
CharlieKerfoot:fix/fixsymlinkmissing
May 7, 2026
Merged

fix(workspaces): reject existing non-symlink in fixSymlinkMissing#686
najmuzzaman-mohammad merged 6 commits into
nex-crm:mainfrom
CharlieKerfoot:fix/fixsymlinkmissing

Conversation

@CharlieKerfoot

@CharlieKerfoot CharlieKerfoot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

fixSymlinkMissing in internal/workspaces/doctor_fix.go:361 swallowed os.ErrExist from os.Symlink, so when ~/.wuphf already existed as a regular file or directory the function returned nil without creating the symlink. That masked the partial-migration case that fixSymlinkWrong explicitly refuses to clobber, leaving doctor reporting success on a still-broken workspace.

Fix

Lstat ~/.wuphf first and, if it exists as a non-symlink, return ErrManualFixRequired pointing at docs/multi-workspace.md. This is the same as fixSymlinkWrong. The os.Symlink call no longer ignores os.ErrExist, so any remaining create failure surfaces as a real error.

Tests

Manually verified by pre-creating ~/.wuphf as 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

  • Bug Fixes
    • Stricter and safer handling of the legacy compatibility symlink: verifies the existing item type, ensures the intended target exists before creating a symlink, refuses to overwrite regular files or directories, and surfaces creation errors instead of ignoring them. Correctly configured symlinks remain untouched; misconfigured or missing symlinks now require explicit manual correction.

@CharlieKerfoot CharlieKerfoot requested review from a team and FranDias as code owners May 6, 2026 15:39
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Enforce stricter symlink creation in fixSymlinkMissing (internal/workspaces/doctor_fix.go): reject existing non-symlink paths, preserve idempotent correct symlink, verify target exists before linking, and surface all os.Symlink errors instead of ignoring EEXIST.

Changes

Stricter Symlink Fix Logic

Layer / File(s) Summary
Pre-check / Existence Type
internal/workspaces/doctor_fix.go
Run os.Lstat on ~/.wuphf; if it exists and is not a symlink, return ErrManualFixRequired (refuse to clobber).
Idempotency Check
internal/workspaces/doctor_fix.go
If ~/.wuphf is a symlink, read target with os.Readlink and return nil when it matches the cleaned expected target.
Target Validation
internal/workspaces/doctor_fix.go
Call os.Stat on the expected symlink target and fail with a wrapped error if the target does not exist.
Symlink Creation / Error Handling
internal/workspaces/doctor_fix.go
Create symlink with os.Symlink and propagate all errors (removed prior special-casing/ignoring of EEXIST).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 I checked the path before a hop,
refused to stomp where roots might stop,
I read the link, then checked the goal,
and surfaced every tiny hole—
hops now safe, no silent drop!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: rejecting existing non-symlink files/directories in the fixSymlinkMissing function.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 win

Merge the two os.Lstat calls to avoid redundant syscalls and TOCTOU window.

os.Lstat(symlinkPath) is called twice (lines 355 and 366) with os.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's if-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

fixSymlinkWrong still swallows os.ErrExist, leaving the symlink uncreated while returning nil.

This PR removes the !errors.Is(err, os.ErrExist) guard from fixSymlinkMissing, but line 402 in fixSymlinkWrong retains it:

if err := os.Symlink(expectedTarget, symlinkPath); err != nil && !errors.Is(err, os.ErrExist) {

After os.Remove succeeds at line 390 (or when lerr != nil at line 384 — path absent), a concurrent actor can create a path at symlinkPath before os.Symlink runs. If os.Symlink then returns EEXIST, the guard swallows the error and the function returns nil — 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

📥 Commits

Reviewing files that changed from the base of the PR and between 872eee5 and 81dcccb.

📒 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.
@najmuzzaman-mohammad

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@najmuzzaman-mohammad najmuzzaman-mohammad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Addresses all CodeRabbit feedback: merged redundant Lstat calls in fixSymlinkMissing, removed ErrExist swallow in fixSymlinkWrong. All symlink tests pass. LGTM.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

fixSymlinkMissing has two unhandled paths that produce confusing errors after the EEXIST swallow was removed.

Issue A — wrong-symlink fallthrough (Case C):
When ~/.wuphf already exists as a symlink but points to the wrong target (or os.Readlink fails for any reason), none of the early-return branches inside if lerr == nil { … } fire, so execution falls through to os.Symlink. Because the path exists, os.Symlink always returns EEXIST, and the caller receives "symlink:missing: create ~/.wuphf → …: symlink: file exists" — an error message that gives no actionable guidance. The previous EEXIST swallow silently returned nil here (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:missing for a symlink whose target has been deleted. os.Lstat finds the dangling symlink, os.Readlink returns the stale (wrong) path, the idempotency check fails, and execution falls straight into os.Symlink → EEXIST.

Issue B — non-ErrNotExist Lstat error swallowed:
If os.Lstat fails for a reason other than the path not existing (e.g., a permission error), lerr is non-nil but the if lerr == nil guard simply skips the entire block. Execution proceeds to os.Stat(expectedTarget) and then os.Symlink, burying the original error under a secondary, unrelated failure. The rest of the file handles this correctly (fixOrphanTreeRegister lines 126–132, fixOrphanTreeDelete lines 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

📥 Commits

Reviewing files that changed from the base of the PR and between 81dcccb and e41d5a2.

📒 Files selected for processing (1)
  • internal/workspaces/doctor_fix.go

@najmuzzaman-mohammad najmuzzaman-mohammad merged commit 649536c into nex-crm:main May 7, 2026
17 checks passed
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.

2 participants