Summary
cds get copies fetched files with shutil.copy2 (getter.py:76) and writes the tracking manifest with write_text (getter.py:571), both of which follow a symlink at the destination path.
Problem
_find_conflicts checks destination.exists() (getter.py:530). A dangling symlink returns False from Path.exists(), so a pre-placed symlink passes the conflict check without --force, and copy2 then creates the target file through the link. With --force, any symlink destination is overwritten through the link. A symlink planted at e.g. profiles/foo/profile.yaml -> ~/.ssh/config gets its target clobbered with fetched content. Shared working directories (CI workspaces, mounted home dirs) are the realistic attack surface.
Suggested fix
- In
_find_conflicts, treat destination.is_symlink() as a conflict regardless of target state.
- Before
copy2 and write_text, refuse (or unlink after confirming the target) any symlink destination.
Files involved
- cli/getter.py (lines 74-76, 526-537, 570-571)
Summary
cds getcopies fetched files withshutil.copy2(getter.py:76) and writes the tracking manifest withwrite_text(getter.py:571), both of which follow a symlink at the destination path.Problem
_find_conflictschecksdestination.exists()(getter.py:530). A dangling symlink returns False fromPath.exists(), so a pre-placed symlink passes the conflict check without --force, andcopy2then creates the target file through the link. With --force, any symlink destination is overwritten through the link. A symlink planted at e.g. profiles/foo/profile.yaml ->~/.ssh/configgets its target clobbered with fetched content. Shared working directories (CI workspaces, mounted home dirs) are the realistic attack surface.Suggested fix
_find_conflicts, treatdestination.is_symlink()as a conflict regardless of target state.copy2andwrite_text, refuse (or unlink after confirming the target) any symlink destination.Files involved