Skip to content

release: v0.2.0 — security fixes, bug fixes, documentation#7

Merged
jyshnkr merged 14 commits intomainfrom
v0.2.0-release
Mar 1, 2026
Merged

release: v0.2.0 — security fixes, bug fixes, documentation#7
jyshnkr merged 14 commits intomainfrom
v0.2.0-release

Conversation

@jyshnkr
Copy link
Owner

@jyshnkr jyshnkr commented Mar 1, 2026

Summary

  • 16 CodeRabbit/Qodo review comments addressed across 5 phases
  • Security hardening, bug fixes, minor improvements, and trivial cleanup
  • Version bumped to v0.2.0, with CHANGELOG.md, README.md, and updated SECURITY.md
  • 97 tests passing (up from 88)

Changes by Phase

Phase 1 — Security Fixes

  • Fix bulk restore path-traversal bypass: restore_backup() else branch now checks source resolves inside backup archive and destination resolves inside $HOME
  • Preserve file permissions on atomic replace in merge_pulled_claude_json(), save_manifest(), save_config()
  • New tests: test_restore_all_rejects_dest_outside_home, test_merge_preserves_file_permissions

Phase 2 — Bug Fixes

  • Rebuild local manifest from post-sync state after a successful pull
  • Add _local_to_remote_path() helper; fix _build_manifests() to translate paths before querying remote hashes
  • Handle FileNotFoundError in get_remote_file_hashes() (missing SSH binary) → SyncError
  • Validate JSON root is a dict in sanitize_claude_json(), merge_pulled_claude_json(), and load_manifest()
  • New tests: test_get_remote_file_hashes_raises_on_missing_ssh, test_sanitize_rejects_non_dict_json, test_merge_rejects_non_dict_remote, test_merge_rejects_non_dict_local, test_load_manifest_rejects_non_dict_json, test_pull_rebuilds_manifest_after_sync

Phase 3 — Minor Improvements

  • Count .claude.json transfers in SyncSummary.files_transferred
  • Validate sync.strategy config field at load time
  • SyncedFileEntry.last_synced is now optional (TypedDict inheritance for Python 3.9 compat)
  • Fix hardcoded /tmp paths in tests; add exit_code assertions; remove unused fixture param
  • New test: test_detect_conflicts_last_sync_none_uses_fallback

Phase 4 — Trivial Cleanup

  • Remove redundant inline comment on FileConflict.winner
  • Make dry_run keyword-only on _rsync_global() and _rsync_project()
  • Tighten test_rsync_project_aggregates_all_failures assertion from >= 2 to == 3

Phase 5 — Release Documentation

  • Version → 0.2.0
  • Add CHANGELOG.md (Keep a Changelog format)
  • Add comprehensive README.md
  • Update SECURITY.md with actual version support table and reporting instructions

Test plan

  • pytest — all 97 tests pass
  • python -c "from claudesync import __version__; print(__version__)"0.2.0
  • git tag --listv0.1.0, v0.2.0
  • Each phase is a separate commit on v0.2.0-release

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation

    • Added README and CHANGELOG (v0.2.0 & v0.1.0) and updated security policy with reporting SLA and guidance.
  • New Features

    • Improved manifest syncing to accurately map local paths to remote paths.
    • Version bumped to 0.2.0.
  • Bug Fixes

    • Hardened restore to prevent path traversal and symlink races.
    • Reject non-object JSON for manifests and sanitized data.
    • Clearer error when SSH is missing; preserve file modes on config/manifest writes.
  • Tests

    • Added regression and security tests for path validation, JSON validation, manifest and sync behavior.

Jayashankar Mangina and others added 5 commits February 28, 2026 21:51
… atomic replace

- Add path-traversal and home-directory guards to the else branch of
  restore_backup() to match the protections already present for the
  single-file branch
- Preserve original file permissions on atomic replace in
  merge_pulled_claude_json(), save_manifest(), and save_config()
- Fix test_restore_backup_all_files to mock Path.home() (required by
  the new home-directory guard)
- Add test_restore_all_rejects_dest_outside_home (backup.py)
- Add test_merge_preserves_file_permissions (sanitize.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…error handler, JSON shape validation

- Rebuild local manifest from post-sync file state after a successful
  pull, so the stored manifest reflects what actually landed on disk
- Add _local_to_remote_path() helper and use it in _build_manifests()
  to translate local absolute paths to remote equivalents before
  querying remote file hashes (fixes hash lookup always missing)
- Handle FileNotFoundError in get_remote_file_hashes() when SSH binary
  is absent, wrapping it as SyncError
- Validate that JSON root is a dict in sanitize_claude_json(),
  merge_pulled_claude_json() (both remote and local sides), and
  load_manifest() — raises ValueError for non-object JSON
- Add tests: test_get_remote_file_hashes_raises_on_missing_ssh,
  test_sanitize_rejects_non_dict_json, test_merge_rejects_non_dict_remote,
  test_merge_rejects_non_dict_local, test_load_manifest_rejects_non_dict_json,
  test_pull_rebuilds_manifest_after_sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ced, test improvements

- Count .claude.json file transfers in SyncSummary.files_transferred
- Validate sync.strategy config field at load time against allowed values
- Make SyncedFileEntry.last_synced optional (TypedDict inheritance) for
  legacy manifest compatibility
- Add test_detect_conflicts_last_sync_none_uses_fallback regression test
- Fix hardcoded /tmp paths in test_cli.py; use tmp_path fixture instead
- Add assert result.exit_code checks to all test_cli.py runner.invoke calls
- Remove unused tmp_path parameter from mock_config() fixture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion

- Remove redundant inline comment from FileConflict.winner field — the
  type WinnerSide | None already documents the possible values
- Make dry_run parameter keyword-only on _rsync_global() and
  _rsync_project() to prevent future accidental positional misuse
- Tighten test_rsync_project_aggregates_all_failures assertion from
  >= 2 to == 3 (3 items all fail, so exactly 3 'error' substrings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bump version to 0.2.0 in pyproject.toml and __init__.py
- Add CHANGELOG.md following Keep a Changelog format, documenting
  all changes in v0.2.0 and v0.1.0
- Add comprehensive README.md covering installation, quick start, CLI
  reference, architecture, configuration, security considerations, and
  development instructions
- Update SECURITY.md: replace GitHub template placeholder content with
  actual supported versions table (0.2.x supported, 0.1.x EOL) and
  vulnerability reporting instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@qodo-code-review
Copy link

Review Summary by Qodo

Release v0.2.0 — security fixes, bug fixes, and documentation

🐞 Bug fix ✨ Enhancement 📝 Documentation

Grey Divider

Walkthroughs

Description
• Security hardening: path-traversal guards in bulk restore, file permission preservation on atomic
  writes
• Bug fixes: manifest rebuild after pull, remote path translation in hash lookup, JSON shape
  validation, missing SSH error handling
• Minor improvements: .claude.json transfer counting, config strategy validation, optional
  last_synced field
• Release v0.2.0 with comprehensive documentation (README, CHANGELOG, SECURITY.md)
Diagram
flowchart LR
  A["Security Hardening"] --> B["Path Traversal Guards"]
  A --> C["File Permission Preservation"]
  D["Bug Fixes"] --> E["Manifest Rebuild After Pull"]
  D --> F["Remote Path Translation"]
  D --> G["JSON Validation"]
  D --> H["SSH Error Handling"]
  I["Enhancements"] --> J["Transfer Counting"]
  I --> K["Config Validation"]
  I --> L["Optional Fields"]
  M["Documentation"] --> N["README.md"]
  M --> O["CHANGELOG.md"]
  M --> P["SECURITY.md"]
  B --> Q["v0.2.0"]
  C --> Q
  E --> Q
  F --> Q
  G --> Q
  H --> Q
  J --> Q
  K --> Q
  L --> Q
  N --> Q
  O --> Q
  P --> Q
Loading

Grey Divider

File Changes

1. src/claudesync/__init__.py ⚙️ Configuration changes +1/-1

Version bump to 0.2.0

src/claudesync/init.py


2. src/claudesync/backup.py 🐞 Bug fix +13/-6

Add path-traversal and home-directory guards to bulk restore

src/claudesync/backup.py


3. src/claudesync/cli.py 🐞 Bug fix +33/-2

Rebuild manifest after pull from post-sync state

src/claudesync/cli.py


View more (15)
4. src/claudesync/config.py ✨ Enhancement +10/-1

Validate sync.strategy and preserve file permissions on save

src/claudesync/config.py


5. src/claudesync/conflicts.py Miscellaneous +1/-1

Remove redundant inline comment on winner field

src/claudesync/conflicts.py


6. src/claudesync/engine.py ✨ Enhancement +5/-2

Count .claude.json transfers, handle missing SSH, make dry_run keyword-only

src/claudesync/engine.py


7. src/claudesync/manifest.py 🐞 Bug fix +15/-3

Make last_synced optional, validate JSON is dict, preserve file permissions

src/claudesync/manifest.py


8. src/claudesync/sanitize.py 🐞 Bug fix +14/-0

Validate JSON root is dict, preserve file permissions on atomic replace

src/claudesync/sanitize.py


9. tests/test_backup.py 🧪 Tests +22/-1

Add security tests for bulk restore path guards

tests/test_backup.py


10. tests/test_cli.py 🧪 Tests +37/-7

Fix hardcoded /tmp paths, add exit_code assertions, test manifest rebuild

tests/test_cli.py


11. tests/test_conflicts.py 🧪 Tests +14/-0

Add test for last_sync=None fallback behavior

tests/test_conflicts.py


12. tests/test_engine.py 🧪 Tests +9/-1

Add test for missing SSH binary error handling

tests/test_engine.py


13. tests/test_manifest.py 🧪 Tests +8/-0

Add test for non-dict JSON rejection in manifest

tests/test_manifest.py


14. tests/test_sanitize.py 🧪 Tests +47/-0

Add tests for JSON shape validation and permission preservation

tests/test_sanitize.py


15. CHANGELOG.md 📝 Documentation +45/-0

Add comprehensive changelog in Keep a Changelog format

CHANGELOG.md


16. README.md 📝 Documentation +150/-0

Add comprehensive installation, usage, and architecture documentation

README.md


17. SECURITY.md 📝 Documentation +11/-11

Update with actual version support table and reporting instructions

SECURITY.md


18. pyproject.toml ⚙️ Configuration changes +1/-1

Bump version to 0.2.0

pyproject.toml


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 1, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Restore-all symlink race🐞 Bug ⛨ Security
Description
Bulk restore validates the resolved destination is under $HOME, but then creates directories and
writes later; a symlink swap between the check and the copy can redirect writes outside $HOME. This
undermines the new path-traversal hardening for restore-all.
Code

src/claudesync/backup.py[R89-102]

+        backup_root = ts_dir.resolve()
+        home_root = Path.home().resolve()
       for src in ts_dir.rglob("*"):
-            if src.is_file():
-                rel = str(src.relative_to(ts_dir))
-                dest = Path("/" + rel)
-                dest.parent.mkdir(parents=True, exist_ok=True)
-                shutil.copy2(src, dest)
-                restored.append(dest)
+            if not src.is_file():
+                continue
+            if not src.resolve().is_relative_to(backup_root):
+                raise ValueError(f"Path traversal detected in backup archive: '{src}'")
+            rel = str(src.relative_to(ts_dir))
+            dest = Path("/" + rel)
+            if not dest.resolve().is_relative_to(home_root):
+                raise ValueError(f"Restore destination outside home directory: '{dest}'")
+            dest.parent.mkdir(parents=True, exist_ok=True)
+            shutil.copy2(src, dest)
+            restored.append(dest)
Evidence
In restore-all, the code checks dest.resolve() is within home_root, then performs mkdir/copy2 using
the unchecked path. Without using no-follow semantics or anchoring writes to a trusted directory fd,
this is a classic TOCTOU/symlink-race pattern (check-then-use on filesystem paths).

src/claudesync/backup.py[89-102]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`restore_backup()` (restore-all branch) does a `dest.resolve().is_relative_to(home_root)` check, but then performs `mkdir` + `copy2` later. This leaves a symlink/TOCTOU window where the destination path can be swapped to point outside `$HOME`.
### Issue Context
This code path is explicitly being security-hardened for path traversal. To make the guard robust, the destination write should not depend on a path that can change between validation and use.
### Fix Focus Areas
- src/claudesync/backup.py[89-102]
### Suggested approach (one viable option)
1. Before writing, validate that **no parent component** between `home_root` and `dest.parent` is a symlink.
2. Open the destination file using `os.open` with `O_NOFOLLOW` (prevents following a symlink as the final component), copy bytes from `src` to that fd, and preserve metadata as needed.
3. Alternatively, write into a temp file in the validated directory and `os.replace` into place, again using no-follow checks for the final target.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. chmod uses raw st_mode 🐞 Bug ⛯ Reliability
Description
New permission-preservation code calls chmod(original_mode) where original_mode is
Path.stat().st_mode (includes file-type bits). This is usually tolerated on POSIX, but it’s
non-idiomatic and can be made clearer/safer by masking to permission bits (stat.S_IMODE).
Code

src/claudesync/config.py[R143-150]

+    original_mode = CONFIG_FILE.stat().st_mode if CONFIG_FILE.exists() else None
   tmp = CONFIG_FILE.with_suffix(".tmp")
   try:
       with tmp.open("wb") as f:
           tomli_w.dump(data, f)
+        if original_mode is not None:
+            tmp.chmod(original_mode)
       tmp.replace(CONFIG_FILE)
Evidence
The PR captures st_mode from an existing file and passes it directly into chmod for the temp file.
The test suite itself masks st_mode to 0o777 when asserting permissions, which highlights that
st_mode carries more than just permission bits; using stat.S_IMODE() avoids relying on
platform-specific masking behavior and makes intent explicit.

src/claudesync/config.py[143-150]
src/claudesync/manifest.py[65-73]
src/claudesync/sanitize.py[97-104]
tests/test_sanitize.py[157-170]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Permission preservation currently uses `Path.stat().st_mode` directly in `chmod()`. `st_mode` includes file-type bits; while most POSIX systems effectively mask these, it’s clearer and safer to explicitly apply only permission bits.
### Issue Context
This pattern appears in config saving, manifest saving, and `.claude.json` merge.
### Fix Focus Areas
- src/claudesync/config.py[143-150]
- src/claudesync/manifest.py[65-73]
- src/claudesync/sanitize.py[97-104]
### Suggested fix
- `import stat`
- Replace `tmp.chmod(original_mode)` with `tmp.chmod(stat.S_IMODE(original_mode))` (or `original_mode &amp;amp; 0o777` / `0o7777` depending on desired special bits).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@coderabbitai
Copy link

coderabbitai bot commented Mar 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds documentation and version bump to 0.2.0; introduces file‑IO safety and permission‑preserving atomic writes, path‑traversal and JSON‑type validations, manifest rebuild with local→remote path translation, rsync/SSH error handling and keyword-only rsync helpers, and corresponding tests — no behavioral changes outside these areas.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md, README.md, SECURITY.md
Add changelog and README; update security policy wording, SLA, reporting instructions, and supported versions.
Version Metadata
pyproject.toml, src/claudesync/__init__.py
Bump project and package version 0.1.0 → 0.2.0.
Backup & Restore Guards
src/claudesync/backup.py, tests/test_backup.py
Validate single-segment backup_id; confine restores to BACKUP_DIR/HOME; add pre/post symlink and path checks; guarded traversal for bulk restores; introduce _atomic_copy; add tests for malicious/outsider paths and symlink races.
Manifest Typing & IO Safety
src/claudesync/manifest.py, tests/test_manifest.py
Split TypedDict into required/optional parts; validate manifest JSON is an object; preserve file mode on atomic manifest writes; add test rejecting non‑dict manifests.
Sanitize JSON Validation & IO Safety
src/claudesync/sanitize.py, tests/test_sanitize.py
Reject non-dict JSON in sanitize/merge functions with explicit errors; preserve local file mode on atomic writes; add tests for invalid JSON and mode preservation.
CLI manifest & path translation
src/claudesync/cli.py, tests/test_cli.py
Add _collect_local_files and _local_to_remote_path; rebuild manifest flow to translate local→remote paths before requesting remote hashes; remap remote hashes back to local keys; tests updated/added for translation and manifest rebuild.
Engine changes
src/claudesync/engine.py, tests/test_engine.py
Make _rsync_global/_rsync_project keyword-only; count transferred files from rsync output; wrap missing ssh (FileNotFoundError) as SyncError; tests added/strengthened for missing-ssh and rsync error aggregation.
Config validation & permissions
src/claudesync/config.py
Validate sync.strategy against allowed values and preserve existing config file permissions when saving.
Minor / Typing
src/claudesync/conflicts.py
Remove an inline comment; no behavioral change.
Tests (other)
tests/test_conflicts.py, tests/test_cli.py, tests/...
Add/adjust tests covering conflict detection fallback, manifest rebuild, CLI/sanitize behaviors, cleanup semantics, and exit codes.
Repo housekeeping
.gitignore
Add .venv/ ignore entry.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as "CLI"
  participant LocalFS as "Local FS"
  participant Engine as "Engine (rsync/SSH)"
  participant Remote as "Remote Host"
  participant Manifest as "Local Manifest"

  rect rgba(200,230,255,0.5)
  CLI->>LocalFS: _collect_local_files() → local paths
  CLI->>CLI: _local_to_remote_path(local_path) → remote_path
  end

  rect rgba(220,255,200,0.5)
  CLI->>Engine: request remote hashes for remote_paths
  Engine->>Remote: SSH/rsync fetch hashes
  Remote-->>Engine: hashes
  Engine-->>CLI: remote hashes (remote_path keys)
  end

  rect rgba(255,230,200,0.5)
  CLI->>CLI: remap remote hashes → local-path keys
  CLI->>Manifest: update_manifest_for_remote(local_manifest, remote_manifest)
  Manifest-->>CLI: manifest saved (mode preserved)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 A tidy hop, a careful guard,

Paths kept safe and JSON unmarred,
Modes preserved, manifests aligned,
Tests now watch each safety bind,
Hooray for 0.2.0 — a carrot hard!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main purpose of the PR: a release version bump to v0.2.0 with security fixes, bug fixes, and documentation additions.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch v0.2.0-release

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

Comment on lines +89 to +102
backup_root = ts_dir.resolve()
home_root = Path.home().resolve()
for src in ts_dir.rglob("*"):
if src.is_file():
rel = str(src.relative_to(ts_dir))
dest = Path("/" + rel)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
restored.append(dest)
if not src.is_file():
continue
if not src.resolve().is_relative_to(backup_root):
raise ValueError(f"Path traversal detected in backup archive: '{src}'")
rel = str(src.relative_to(ts_dir))
dest = Path("/" + rel)
if not dest.resolve().is_relative_to(home_root):
raise ValueError(f"Restore destination outside home directory: '{dest}'")
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
restored.append(dest)

Choose a reason for hiding this comment

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

Action required

1. Restore-all symlink race 🐞 Bug ⛨ Security

Bulk restore validates the resolved destination is under $HOME, but then creates directories and
writes later; a symlink swap between the check and the copy can redirect writes outside $HOME. This
undermines the new path-traversal hardening for restore-all.
Agent Prompt
### Issue description
`restore_backup()` (restore-all branch) does a `dest.resolve().is_relative_to(home_root)` check, but then performs `mkdir` + `copy2` later. This leaves a symlink/TOCTOU window where the destination path can be swapped to point outside `$HOME`.

### Issue Context
This code path is explicitly being security-hardened for path traversal. To make the guard robust, the destination write should not depend on a path that can change between validation and use.

### Fix Focus Areas
- src/claudesync/backup.py[89-102]

### Suggested approach (one viable option)
1. Before writing, validate that **no parent component** between `home_root` and `dest.parent` is a symlink.
2. Open the destination file using `os.open` with `O_NOFOLLOW` (prevents following a symlink as the final component), copy bytes from `src` to that fd, and preserve metadata as needed.
3. Alternatively, write into a temp file in the validated directory and `os.replace` into place, again using no-follow checks for the final target.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 133-140: The fenced code block showing the project tree in
README.md is missing a language specifier; update the opening backticks for that
block (the ``` line before the tree listing starting with ".├──
src/claudesync/") to include a language like "text" or "plaintext" (e.g., change
``` to ```text) so the project structure tree is rendered with correct plaintext
formatting.
- Around line 73-83: The fenced code block showing the project tree (the lines
starting with ``` and the block beginning "src/claudesync/") lacks a language
specifier; update the opening fence to include a language such as "text" or
"plaintext" (e.g., change ``` to ```text) so markdown linters recognize it as a
plaintext/code block while leaving the block contents unchanged.

In `@SECURITY.md`:
- Line 14: Replace the placeholder owner in the security advisory URL string
`https://github.com/<owner>/claudesync/security/advisories/new` with the
repository's actual GitHub username or organization (e.g.,
`https://github.com/my-org/claudesync/security/advisories/new`) so the link
points to the real advisory page; update the string in SECURITY.md accordingly.

In `@src/claudesync/backup.py`:
- Around line 89-90: Ensure the code constrains backup_id to BACKUP_DIR before
computing restore roots: build ts_dir by joining BACKUP_DIR and backup_id (not
using backup_id directly if absolute or containing ..), then resolve ts_dir and
verify the resolved path is a child of BACKUP_DIR (e.g., compare
resolved_ts_dir.is_relative_to(resolved_backup_dir) or check str(prefix) match);
if the check fails, raise an error and do not proceed. After this validation set
backup_root = ts_dir.resolve() and home_root = Path.home().resolve() so restore
roots cannot escape the intended BACKUP_DIR boundary.

In `@src/claudesync/cli.py`:
- Around line 230-236: Create a new helper (e.g.,
collect_local_files(project_paths)) that calls get_global_include_paths(),
iterates project_paths and PROJECT_SYNC_ITEMS, appends str(p) for existing
paths, and returns the consolidated list of local file paths; then replace the
duplicated loops in pull and _build_manifests with a call to this helper and
pass its result into build_local_manifest/update_manifest_for_remote so behavior
remains identical (use existing symbols PROJECT_SYNC_ITEMS,
get_global_include_paths, build_local_manifest, update_manifest_for_remote,
pull, and _build_manifests to locate and update the code).
- Around line 383-394: The _local_to_remote_path function currently does string
prefix checks using hardcoded "/" which fails on Windows; change it to operate
with pathlib.Path objects and use Path.relative_to() to compute the relative
path to each project path or to the home directory (catching ValueError when not
relative), then join with remote.remote_home and proj.name using pathlib
semantics (or ensure "/"-normalized remote paths) so Windows backslashes are
handled correctly and the function returns the expected remote path for matches;
keep the fallback of returning the original local_path when no relative match is
found.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 42cb7a9 and f03f5b5.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • README.md
  • SECURITY.md
  • pyproject.toml
  • src/claudesync/__init__.py
  • src/claudesync/backup.py
  • src/claudesync/cli.py
  • src/claudesync/config.py
  • src/claudesync/conflicts.py
  • src/claudesync/engine.py
  • src/claudesync/manifest.py
  • src/claudesync/sanitize.py
  • tests/test_backup.py
  • tests/test_cli.py
  • tests/test_conflicts.py
  • tests/test_engine.py
  • tests/test_manifest.py
  • tests/test_sanitize.py

Jayashankar Mangina and others added 2 commits February 28, 2026 22:54
- backup.py: validate backup_id resolves inside BACKUP_DIR before use
  (prevents path traversal via crafted backup IDs like '../../etc')
- backup.py: add symlink check on dest immediately before shutil.copy2
  in both single-file and bulk restore branches (mitigates TOCTOU race)
- cli.py: rewrite _local_to_remote_path using Path.relative_to() and
  as_posix() instead of string startswith(); works correctly on all OSes
- tests: add test_restore_backup_rejects_traversal_in_backup_id
- tests: add test_local_to_remote_path_* unit tests (project, home, unrelated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cli.py: extract _collect_local_files(project_paths) helper; replace
  duplicated local-file collection loops in pull() and _build_manifests()
- README.md: add 'text' language specifier to bare code fences in
  Architecture and Project structure sections (MD040)
- SECURITY.md: replace <owner> placeholder with jyshnkr in advisory URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/claudesync/backup.py`:
- Around line 67-72: The code currently only checks that ts_dir is inside
BACKUP_DIR but allows multi-segment backup_id like "." or "a/.." which can
escape intentions; before constructing ts_dir or resolving, validate that
backup_id is exactly one path segment (e.g. using pathlib.Path(backup_id).parts
has length 1 and the segment is not "." or ".." and does not contain os.sep),
then proceed to build ts_dir = BACKUP_DIR / backup_id and keep the existing
is_relative_to/exists checks; reference the variables backup_id, ts_dir and
constant BACKUP_DIR when adding this validation.

In `@tests/test_cli.py`:
- Line 188: The assertion in tests/test_cli.py is using an unnecessary f-string
(assert result == f"/home/u/MyProject/CLAUDE.md") which triggers ruff F541;
change it to a plain string by removing the leading f so the assertion reads
assert result == "/home/u/MyProject/CLAUDE.md" (keep the same comparison target
and the variable result).

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f03f5b5 and bce109b.

📒 Files selected for processing (6)
  • README.md
  • SECURITY.md
  • src/claudesync/backup.py
  • src/claudesync/cli.py
  • tests/test_backup.py
  • tests/test_cli.py

Comment on lines 67 to 72
ts_dir = BACKUP_DIR / backup_id
# Guard: backup_id must resolve inside BACKUP_DIR
if not ts_dir.resolve().is_relative_to(BACKUP_DIR.resolve()):
raise ValueError(f"Invalid backup id outside backup directory: '{backup_id}'")
if not ts_dir.exists():
raise ValueError(f"Backup '{backup_id}' not found in {BACKUP_DIR}")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restrict backup_id to a single directory segment.

Current checks only enforce “inside BACKUP_DIR”, so values like "." or "20260101T000000/.." can still pass and widen restore scope unexpectedly.

🔐 Proposed fix
 def restore_backup(backup_id: str, original_path: str | None = None) -> list[Path]:
@@
-    ts_dir = BACKUP_DIR / backup_id
+    backup_id_path = Path(backup_id)
+    if backup_id in {"", ".", ".."} or backup_id_path.name != backup_id:
+        raise ValueError(f"Invalid backup id: '{backup_id}'")
+
+    ts_dir = BACKUP_DIR / backup_id
     # Guard: backup_id must resolve inside BACKUP_DIR
     if not ts_dir.resolve().is_relative_to(BACKUP_DIR.resolve()):
         raise ValueError(f"Invalid backup id outside backup directory: '{backup_id}'")
-    if not ts_dir.exists():
+    if not ts_dir.exists() or not ts_dir.is_dir():
         raise ValueError(f"Backup '{backup_id}' not found in {BACKUP_DIR}")
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 70-70: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 72-72: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/claudesync/backup.py` around lines 67 - 72, The code currently only
checks that ts_dir is inside BACKUP_DIR but allows multi-segment backup_id like
"." or "a/.." which can escape intentions; before constructing ts_dir or
resolving, validate that backup_id is exactly one path segment (e.g. using
pathlib.Path(backup_id).parts has length 1 and the segment is not "." or ".."
and does not contain os.sep), then proceed to build ts_dir = BACKUP_DIR /
backup_id and keep the existing is_relative_to/exists checks; reference the
variables backup_id, ts_dir and constant BACKUP_DIR when adding this validation.


result = _local_to_remote_path(local_path, [proj], remote)

assert result == f"/home/u/MyProject/CLAUDE.md"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

sed -n '180,195p' tests/test_cli.py

Repository: jyshnkr/claudesync

Length of output: 848


🏁 Script executed:

rg -n "assert result == f" tests/test_cli.py

Repository: jyshnkr/claudesync

Length of output: 117


🏁 Script executed:

# Check if there's a lint/flake8 configuration that enforces F541
find . -maxdepth 2 -name ".flake8" -o -name "pyproject.toml" -o -name "setup.cfg" -o -name "ruff.toml" -o -name ".ruff.toml" | head -20

Repository: jyshnkr/claudesync

Length of output: 90


🏁 Script executed:

# Check ruff configuration for F541
cat ruff.toml

Repository: jyshnkr/claudesync

Length of output: 1877


🏁 Script executed:

# Also check pyproject.toml for any linting config
cat pyproject.toml

Repository: jyshnkr/claudesync

Length of output: 658


Remove the unnecessary f-string prefix at this assertion.

Line 188 contains an f-string literal without any placeholders (e.g., {variable}), which triggers ruff's F541 rule. The project's ruff.toml enforces Pyflakes rules and does not ignore F541, so this will fail linting checks.

🧹 Proposed fix
-    assert result == f"/home/u/MyProject/CLAUDE.md"
+    assert result == "/home/u/MyProject/CLAUDE.md"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert result == f"/home/u/MyProject/CLAUDE.md"
assert result == "/home/u/MyProject/CLAUDE.md"
🧰 Tools
🪛 Ruff (0.15.2)

[error] 188-188: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli.py` at line 188, The assertion in tests/test_cli.py is using
an unnecessary f-string (assert result == f"/home/u/MyProject/CLAUDE.md") which
triggers ruff F541; change it to a plain string by removing the leading f so the
assertion reads assert result == "/home/u/MyProject/CLAUDE.md" (keep the same
comparison target and the variable result).

Prevents path traversal via crafted backup_id values like "..", ".",
or "a/.." that could escape BACKUP_DIR before the ts_dir is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jyshnkr
Copy link
Owner Author

jyshnkr commented Mar 1, 2026

PR Review Fix: backup_id path traversal validation

Addressed the security finding flagged in review.

Finding 1 (fixed): restore_backup() in backup.py constructed ts_dir = BACKUP_DIR / backup_id without validating that backup_id was a single safe path segment. Crafted values like "..", ".", or "a/.." could escape BACKUP_DIR before the is_relative_to guard ran.

Fix (commit 1a92b98):

  • Added validation at the top of restore_backup() that rejects any backup_id whose Path.parts has more than one segment, or whose only part is "." / "..", or that contains os.sep
  • Added @pytest.mark.parametrize test covering "..", ".", "a/..", "../etc", "../../etc"

Finding 2 (not applicable): The stale f-string reference in test_cli.py line 188 — that line does not exist in the current code. No action needed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/claudesync/backup.py (1)

77-78: ⚠️ Potential issue | 🟠 Major

Also require ts_dir to be a directory.

Line 77 only checks existence. A file at BACKUP_DIR/<backup_id> would pass and lead to invalid restore behavior. Validate ts_dir.is_dir() before proceeding.

🔧 Proposed fix
-    if not ts_dir.exists():
+    if not ts_dir.exists() or not ts_dir.is_dir():
         raise ValueError(f"Backup '{backup_id}' not found in {BACKUP_DIR}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/claudesync/backup.py` around lines 77 - 78, The code currently only
checks ts_dir.exists() before proceeding; update the validation to ensure ts_dir
is a directory (use ts_dir.is_dir()) and raise the same ValueError if it is
missing or not a directory so a regular file at BACKUP_DIR/<backup_id> can't be
mistaken for a backup; specifically modify the check around ts_dir (referencing
ts_dir, BACKUP_DIR, and backup_id in this function in src/claudesync/backup.py)
to validate is_dir() (e.g., if not ts_dir.exists() or not ts_dir.is_dir(): raise
ValueError(...)) so invalid restore behavior is avoided.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/claudesync/backup.py`:
- Around line 94-99: The current TOCTOU check around dest.is_symlink() and
shutil.copy2(backup_file_path, dest) is unsafe; before writing in the function
that appends to restored, re-validate the parent/resolved-path and perform an
atomic replace: ensure dest.parent.resolve() (or dest.parent.is_symlink()) is
checked immediately before the write, create a temporary file in the same
directory (e.g., use tempfile.NamedTemporaryFile(dir=dest.parent, delete=False)
or similar), copy the backup into that temp file (preserving metadata with
shutil.copy2 to the temp), fsync if needed, then call os.replace(temp_path,
dest) to atomically replace the destination; finally re-check dest.is_symlink()
or parent symlink status where appropriate and append dest to restored only
after successful os.replace. Apply the same pattern to the other block around
shutil.copy2 at lines ~112-117.

In `@tests/test_backup.py`:
- Line 129: The test function test_restore_backup_rejects_malicious_backup_id
declares an unused fixture backup_dir which should be removed to avoid ARG001;
update the function signature to drop the backup_dir parameter (leaving bad_id
only) so the test no longer requests that fixture and rerun tests to ensure no
other references to backup_dir remain in that test.

---

Duplicate comments:
In `@src/claudesync/backup.py`:
- Around line 77-78: The code currently only checks ts_dir.exists() before
proceeding; update the validation to ensure ts_dir is a directory (use
ts_dir.is_dir()) and raise the same ValueError if it is missing or not a
directory so a regular file at BACKUP_DIR/<backup_id> can't be mistaken for a
backup; specifically modify the check around ts_dir (referencing ts_dir,
BACKUP_DIR, and backup_id in this function in src/claudesync/backup.py) to
validate is_dir() (e.g., if not ts_dir.exists() or not ts_dir.is_dir(): raise
ValueError(...)) so invalid restore behavior is avoided.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bce109b and 1a92b98.

📒 Files selected for processing (2)
  • src/claudesync/backup.py
  • tests/test_backup.py

Jayashankar Mangina and others added 4 commits March 1, 2026 10:16
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract _atomic_copy helper that writes to a tempfile in dest.parent,
then os.replace() atomically renames it to dest. Checks both dest and
dest.parent for symlinks. Replace both single-file and bulk restore
write blocks with _atomic_copy calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Validation fires before BACKUP_DIR is ever accessed, so the fixture
was never needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A regular file at BACKUP_DIR/<backup_id> would pass exists() but is not
a valid backup. is_dir() correctly rejects both non-existent and
wrong-type entries. Add test to cover the regular-file case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jyshnkr
Copy link
Owner Author

jyshnkr commented Mar 1, 2026

PR Review Fix Round 2

Addressed three additional review findings:

Fix 1 — Atomic restore writes (TOCTOU symlink race)

Extracted _atomic_copy(src, dest) helper that:

  • Checks dest and dest.parent for symlinks
  • Writes to a NamedTemporaryFile in dest.parent, then os.replace() for atomic rename
  • Cleans up the tempfile on any exception

Both the single-file and bulk restore paths now use _atomic_copy instead of a bare shutil.copy2. Mirrors the existing pattern in sanitize.py / cli.py.

Fix 2 — Unused backup_dir fixture

Dropped the backup_dir parameter from test_restore_backup_rejects_malicious_backup_id — validation fires before BACKUP_DIR is ever accessed so the fixture was never needed.

Fix 3 — is_dir() instead of exists() for backup dir guard

Changed ts_dir.exists()ts_dir.is_dir() so a regular file at BACKUP_DIR/<backup_id> is correctly rejected. Added test_restore_backup_rejects_file_at_backup_id_path to cover this case.

16/16 backup tests pass, ruff clean.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/claudesync/backup.py (1)

117-129: ⚠️ Potential issue | 🟠 Major

_atomic_copy still has a parent-path TOCTOU window.

dest.parent.is_symlink() is checked before using dest.parent in NamedTemporaryFile(...)/os.replace(...). A parent-path swap between check and use can still redirect writes.

🔐 Proposed hardening
-def _atomic_copy(src: Path, dest: Path) -> None:
+def _atomic_copy(src: Path, dest: Path, home_root: Path) -> None:
     """Copy src to dest atomically via a temp file to prevent symlink races."""
-    if dest.is_symlink():
-        raise ValueError(f"Restore destination is a symlink: '{dest}'")
-    if dest.parent.is_symlink():
-        raise ValueError(f"Restore destination parent is a symlink: '{dest.parent}'")
-    fd = tempfile.NamedTemporaryFile(dir=dest.parent, delete=False)
+    resolved_parent = dest.parent.resolve()
+    if not resolved_parent.is_relative_to(home_root):
+        raise ValueError(f"Restore destination outside home directory: '{dest}'")
+    target = resolved_parent / dest.name
+    fd = tempfile.NamedTemporaryFile(dir=resolved_parent, delete=False)
     tmp = Path(fd.name)
     fd.close()
     try:
         shutil.copy2(src, tmp)
-        os.replace(tmp, dest)
+        os.replace(tmp, target)
     except BaseException:
         tmp.unlink(missing_ok=True)
         raise
-        _atomic_copy(backup_file_path, dest)
+        _atomic_copy(backup_file_path, dest, Path.home().resolve())

-            _atomic_copy(src, dest)
+            _atomic_copy(src, dest, home_root)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/claudesync/backup.py` around lines 117 - 129, The TOCTOU exists because
dest.parent is checked with is_symlink() then used; fix _atomic_copy by opening
the parent directory atomically and proving it hasn't changed before creating
the temp file: obtain a dirfd via os.open(dest.parent,
os.O_RDONLY|os.O_DIRECTORY), compare os.fstat(dirfd).(st_ino,st_dev) to
os.lstat(dest.parent).(st_ino,st_dev) to ensure the same directory, then create
the temporary file against that opened directory (e.g. use
tempfile.NamedTemporaryFile(dir=f"/proc/self/fd/{dirfd}", delete=False) or use
os.openat to create the temp file), perform shutil.copy2 to the temp path and
os.replace to dest, and finally close the dirfd and ensure the temp file is
cleaned up on exceptions; update symbols: _atomic_copy, dest.parent,
tempfile.NamedTemporaryFile, os.open/os.openat, os.replace.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/claudesync/backup.py`:
- Around line 117-129: The TOCTOU exists because dest.parent is checked with
is_symlink() then used; fix _atomic_copy by opening the parent directory
atomically and proving it hasn't changed before creating the temp file: obtain a
dirfd via os.open(dest.parent, os.O_RDONLY|os.O_DIRECTORY), compare
os.fstat(dirfd).(st_ino,st_dev) to os.lstat(dest.parent).(st_ino,st_dev) to
ensure the same directory, then create the temporary file against that opened
directory (e.g. use tempfile.NamedTemporaryFile(dir=f"/proc/self/fd/{dirfd}",
delete=False) or use os.openat to create the temp file), perform shutil.copy2 to
the temp path and os.replace to dest, and finally close the dirfd and ensure the
temp file is cleaned up on exceptions; update symbols: _atomic_copy,
dest.parent, tempfile.NamedTemporaryFile, os.open/os.openat, os.replace.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a92b98 and f8f66f3.

📒 Files selected for processing (3)
  • .gitignore
  • src/claudesync/backup.py
  • tests/test_backup.py

Replace naive is_symlink() + NamedTemporaryFile with:
1. os.lstat(dest.parent) to check the link mode and capture inode/dev
2. os.open(dest.parent, O_RDONLY|O_NOFOLLOW|O_DIRECTORY) to get a dirfd
   that fails immediately if dest.parent is a symlink
3. os.fstat(dirfd) inode/dev comparison to detect a swap between
   lstat and open (closes the TOCTOU window)
4. os.openat(dirfd, ...) to create the temp file directly inside the
   verified dirfd; falls back to os.open(full_path) on platforms
   where openat is unavailable (e.g. some macOS Python builds)

Add test_atomic_copy_rejects_symlink_dest and
test_atomic_copy_rejects_symlink_parent to cover both rejection paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jyshnkr
Copy link
Owner Author

jyshnkr commented Mar 1, 2026

PR Review Fix Round 3 — TOCTOU in _atomic_copy

Finding verified: the dest.parent.is_symlink() check followed by NamedTemporaryFile(dir=dest.parent) had a race window — an attacker could swap dest.parent for a symlink between those two calls.

Fix (3877d9d):

  1. os.lstat(dest.parent) — captures inode/dev and checks link mode without traversal
  2. os.open(dest.parent, O_RDONLY|O_NOFOLLOW|O_DIRECTORY) — opens the actual directory entry; fails with ELOOP/OSError if dest.parent is a symlink at open time
  3. os.fstat(dirfd) inode/dev comparison — detects a directory swap between the lstat and the open, closing the TOCTOU window
  4. Temp file creation inside the verified dirfd: os.openat(dirfd, ...) when available, os.open(full_path, ...) as a fallback on platforms where openat is absent (e.g. some macOS Python 3.14 builds — the inode check makes this safe)

Two new unit tests added: test_atomic_copy_rejects_symlink_dest and test_atomic_copy_rejects_symlink_parent. 18/18 tests pass.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/claudesync/backup.py`:
- Around line 127-128: The except block catching FileNotFoundError should
preserve the original exception when re-raising as a ValueError for better
traceability; change the handler so it captures the original exception (e.g.,
except FileNotFoundError as e) and re-raises ValueError(f"Restore destination
parent does not exist: '{dest.parent}'") from e — locate the FileNotFoundError
except in src/claudesync/backup.py (the block referencing dest.parent) and make
this single-line change to chain the exceptions.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f8f66f3 and 3877d9d.

📒 Files selected for processing (2)
  • src/claudesync/backup.py
  • tests/test_backup.py

Preserves the original exception for better traceability in tracebacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jyshnkr
Copy link
Owner Author

jyshnkr commented Mar 1, 2026

PR Review Fix Round 4 — Exception chaining in _atomic_copy

Finding verified: except FileNotFoundError: discarded the original exception, breaking __cause__ chaining and making tracebacks harder to follow.

Fix (5a92e68): except FileNotFoundError as e: + raise ValueError(...) from e — one-line change, no behaviour change.

@jyshnkr jyshnkr merged commit 16b724d into main Mar 1, 2026
3 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.

1 participant