Skip to content

fix(cli): reject and never write through symlinked cds get destinations - #509

Merged
SemTiOne merged 6 commits into
mainfrom
fix/474-symlink-write-through
Aug 25, 2026
Merged

fix(cli): reject and never write through symlinked cds get destinations#509
SemTiOne merged 6 commits into
mainfrom
fix/474-symlink-write-through

Conversation

@RonaldHensbergen

Copy link
Copy Markdown
Owner

Summary

cds get copied fetched files with shutil.copy2 and wrote the tracking manifest with write_text, both of which follow a symlink at the destination path.

_find_conflicts used destination.exists(), which follows symlinks and returns False for a dangling symlink. A pre-planted symlink at a destination path (e.g. profiles/foo/profile.yaml -> ~/.ssh/config) therefore silently bypassed the conflict check even without --force, and the subsequent copy2 wrote through the link, clobbering an arbitrary file outside the intended destination tree. With --force, any symlink destination was overwritten through the link the same way. Shared working directories (CI workspaces, mounted home dirs) are the realistic attack surface.

Fix

  • _find_conflicts now treats destination.is_symlink() as a conflict unconditionally (dangling or not), so it's always reported and blocks the operation without --force.
  • A new _write_actions() helper unlinks any symlink found at a destination path before calling shutil.copy2, so even with --force the destination is always replaced with a regular file rather than written through.
  • _write_tracking_manifest gets the same treatment for .cds/get-manifest.json.

Testing

  • Added 3 new regression tests in tests/test_getter.py:
    • a symlinked destination is rejected without --force, leaving the symlink and its target untouched;
    • with --force, the symlinked destination is replaced with a regular file (not written through), leaving the former target untouched;
    • the tracking manifest path gets the same symlink guard.
    • (Skipped on Windows, where creating symlinks requires elevated privileges; these run on the Ubuntu/macOS CI matrix.)
  • python -m unittest tests.test_getter -v — 17 pass, 4 skipped (symlink tests skip on Windows; permission test also pre-existing Windows skip)
  • python -m ruff check cli/getter.py tests/test_getter.py — clean
  • python -m unittest discover -s tests -p "test_*.py" — only the pre-existing 9 baseline Windows/docker-less failures (unrelated)

Resolves #474

_find_conflicts() previously used Path.exists(), which follows symlinks
and returns False for a dangling link. A pre-planted symlink at a
destination path (e.g. profiles/foo/profile.yaml -> ~/.ssh/config) would
therefore silently bypass the conflict check even without --force, and
shutil.copy2()/write_text() would then write through the link, clobbering
an arbitrary file outside the intended destination tree.

- cli/getter.py: _find_conflicts() now treats any symlink destination
  (dangling or not) as a conflict. A new _write_actions() helper unlinks
  any symlink at a destination before shutil.copy2() so --force can never
  write through a symlink either; _write_tracking_manifest() applies the
  same guard to the .cds/get-manifest.json path.
- tests/test_getter.py: added regression tests confirming a symlinked
  destination is rejected without --force, is replaced (not written
  through) with --force, and that the tracking manifest path gets the
  same treatment. Skipped on Windows, where creating symlinks requires
  elevated privileges; still exercised by the Ubuntu/macOS CI runners.

Resolves #474

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_add_copy_action() previously called .resolve() on the combined
destination path (destination_root / repo_relative), which follows a
symlink planted at the destination leaf and silently swaps the
CopyAction's destination for the symlink's resolved target instead of
the symlink path itself. That defeated the is_symlink() conflict/
write-through guards added for #474: the CI (ubuntu-latest) run of the
new regression tests caught this, since Windows locally could not
create the test symlinks to exercise it.

destination_root is already an absolute, resolved path by the time it
reaches _build_copy_plan()/_add_copy_action() (see fetch_profile()), so
joining without a further .resolve() is sufficient and keeps the
destination path intact for the symlink checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@SemTiOne SemTiOne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. 2 non-blocking issue below. Also resolve the conflict.

Comment thread cli/getter.py
Comment thread tests/test_getter.py
@SemTiOne

Copy link
Copy Markdown
Collaborator

There's a conflict.

@SemTiOne

Copy link
Copy Markdown
Collaborator

Oh conflict again.

@SemTiOne
SemTiOne merged commit dc539a9 into main Aug 25, 2026
11 checks passed
@SemTiOne
SemTiOne deleted the fix/474-symlink-write-through branch August 25, 2026 09:46
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.

cds get writes through destination symlinks

3 participants