Skip to content

fix(cli): validate version strings on --revert before pip handoff#83

Merged
cmeans-claude-dev[bot] merged 2 commits into
mainfrom
fix/version-string-validation
May 1, 2026
Merged

fix(cli): validate version strings on --revert before pip handoff#83
cmeans-claude-dev[bot] merged 2 commits into
mainfrom
fix/version-string-validation

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented May 1, 2026

Summary

Closes #40.

cli/version.py::_do_revert previously fed target_version (or the previous_version loaded from ~/.local/state/mcp-synology/global.yaml) straight into subprocess.run(["uv", "tool", "install", "--force", f"mcp-synology=={prev}"]). shell=False already neutralized command injection, but a value like --revert latest, --revert 1.2, or --revert "1.0.0; whatever" produced an opaque pip "Invalid requirement" error instead of an actionable CLI message — and a hand-corrupted previous_version field in the state file would propagate the same garbage on a no-arg --revert.

What changed

  • New _validate_version_string() helper applies a loose PEP 440-ish regex (^\d+\.\d+\.\d+([-.]?[a-zA-Z0-9]+)*$) that accepts 0.5.1, 0.5.1-rc1, 0.5.0a1, 1.2.3.post4, etc. and rejects empty / whitespace-only input, missing patch segments, leading/trailing whitespace, latest, v-prefixes, shell metacharacters, and path-traversal payloads. On rejection it raises click.ClickException so click renders the standard Error: ... line and exits 1 — the same shape as the other CLI error paths — and the message names the expected format.
  • Validation runs once at the chokepoint inside _do_revert (after prev is resolved from either source) so both the --revert <VER> and corrupt-state code paths are covered with one call site.

Why not also --auto-upgrade?

The issue title mentions both flags, but --auto-upgrade is a click.Choice(["enable", "disable"]) — click rejects anything else before the value reaches our code. And the _do_auto_upgrade upgrade target is a literal mcp-synology@latest, not user input. So no version validation is needed on that path.

Tests

23 new test cases:

  • TestValidateVersionString: 8 valid versions accepted, 13 invalid inputs rejected (including the four examples called out in the acceptance criteria: latest, 1.2, , 1.0.0; whatever, plus arg-injection probes like 1.0.0 --extra and ==1.2.3 and a path-traversal payload).
  • TestDoRevert: two regression tests verifying that an invalid explicit --revert <VER> and a corrupt state-file previous_version both raise ClickException and never reach subprocess.run.

577 unit tests pass at 96.14% coverage. ruff/mypy clean.

Test plan

  • uv run pytest tests/core/test_cli_version.py -v — all 63 tests pass (was 40)
  • uv run pytest — full unit suite green
  • uv run ruff check src/ tests/
  • uv run ruff format --check src/ tests/
  • uv run mypy src/
  • Smoke (post-install): mcp-synology --revert latestError: Invalid version string: 'latest'. Expected MAJOR.MINOR.PATCH ... + exit 1
  • Smoke: mcp-synology --revert 0.5.0 still works as before (mocked subprocess in tests; real-world path unchanged)

🤖 Generated with Claude Code

Closes #40.

`cli/version.py::_do_revert` fed `target_version` (and the
`previous_version` loaded from `~/.local/state/mcp-synology/global.yaml`)
straight into `subprocess.run(["uv", "tool", "install", "--force",
f"mcp-synology=={prev}"])`. `shell=False` already neutralized command
injection, but a value like `--revert latest`, `--revert 1.2`, or
`--revert "1.0.0; whatever"` produced an opaque pip "Invalid
requirement" error instead of an actionable CLI message — and a
hand-corrupted `previous_version` field in the state file would
propagate the same garbage on a no-arg `--revert`.

New `_validate_version_string()` helper applies a loose PEP 440-ish
regex (`^\d+\.\d+\.\d+([-.]?[a-zA-Z0-9]+)*$`) that accepts `0.5.1`,
`0.5.1-rc1`, `0.5.0a1`, `1.2.3.post4`, etc. and rejects empty /
whitespace-only input, missing patch segments, leading / trailing
whitespace, `latest`, `v`-prefixes, shell metacharacters, and path
traversal. On rejection it raises `click.ClickException` so click
renders the standard `Error: ...` line and exits 1, matching the
other CLI error paths and naming the expected format.

Validation runs once at the chokepoint inside `_do_revert` (after
`prev` is resolved from either source) so both the `--revert <VER>`
and corrupt-state code paths are covered. `--auto-upgrade` doesn't
need validation: its click `Choice(["enable", "disable"])` rejects
anything else, and the `_do_auto_upgrade` upgrade target is a literal
`mcp-synology@latest` not a user-supplied version.

Twenty new tests: 8 valid versions accepted, 12 invalid inputs
rejected, plus two `_do_revert` regression tests verifying that an
invalid explicit `--revert <VER>` and a corrupt state-file
`previous_version` both raise `ClickException` and never reach
`subprocess.run`. 577 unit tests pass at 96.14% coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA label May 1, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA Round 1 — Failed (one substantive finding, doc drift only)

Verified

Reviewed both commits (7841891 fix + 1f1234d CHANGELOG backfill), regex shape, validator placement, click wiring, and the --auto-upgrade exclusion claim.

  • _validate_version_string() uses the regex from issue #40 verbatim, raises click.ClickException with format hint, runs once at the chokepoint inside _do_revert after prev is resolved from either source — so both --revert <VER> and corrupt-state paths are covered with one call site.
  • --auto-upgrade exclusion holds. Click Choice(["enable", "disable"]) filters at parse time; _do_auto_upgrade shells mcp-synology@latest (uv) or pipx upgrade mcp-synology — neither path takes user-supplied version input. state["previous_version"] = current writes the result of importlib.metadata.version(), and the revert path validates that value if/when it's read back.
  • Issue #40 acceptance criteria: regex (✔), Error: ... + exit 1 + format hint (✔), no other ingress site needs the regex (✔, verified by grep -rn "revert\|_validate_version_string" src/ — main.py:78 is the only ingress), unit tests cover all four AC examples latest / 1.2 / / 1.0.0; whatever (✔), CHANGELOG ### Fixed entry under ## Unreleased (✔).
  • Local stack on 1f1234d: uv run pytest reports 577 passed / 100 deselected (= integration+vdsm, run separately) / 96.14% coverage. ruff check, ruff format --check, mypy all clean. Matches the CHANGELOG entry verbatim.
  • CI: 12/12 required checks green incl. vdsm integration tests SUCCESS.
  • Smoke against the installed CLI (uv run mcp-synology --revert <X>), all returning Error: Invalid version string: '<X>'. Expected MAJOR.MINOR.PATCH with optional pre/post suffix (e.g. 0.5.1, 0.5.1-rc1, 1.2.3.post4). + exit 1: latest, 1.2, , 1.0.0; whatever, v1.2.3, 1.0.0 --extra. Valid --revert 0.5.1 (current) → Already running 0.5.1 — nothing to do. exit 0. Pre-release --revert 0.5.1-rc1 passes the validator and reaches the existing "Cannot detect package manager" branch (because uv run isn't a uv tool-installed entry).

Findings

F1 (substantive — doc drift). PR body + commit 7841891 body understate the new-test count.

The parametrize lists in tests/core/test_cli_version.py add 8 valid + 13 invalid + 2 regression = 23 new test cases. The PR body and commit message both say:

  • "Twenty new tests: 8 valid versions accepted, 12 invalid inputs rejected"
  • "all 63 tests pass (was 43)"

git show origin/main:tests/core/test_cli_version.py | pytest --collect-only reports 40 as the baseline, and 40 + 23 = 63 (= the actual 577 - 554 delta in the CHANGELOG). The 13th invalid case is "1.0.0 --extra", which is the most security-relevant input in the list (an arg-injection attempt rejected before reaching subprocess.run) — undercutting it in the body is the wrong place to lose visibility.

Suggested fix (PR body only — single edit, no code change needed):

  • s/Twenty new tests/Twenty-three new tests/
  • s/12 invalid inputs rejected/13 invalid inputs rejected/ (occurs twice in body — Summary and the ## Tests section)
  • s/(was 43)/(was 40)/ in the test plan

The CHANGELOG entry's "577 unit tests pass at 96.14% coverage" has no per-test counts and is correct as-is. Commit message of 7841891 carries the same drift; can be amended on the round-2 push if convenient, but PR body is the canonical user-facing description and the higher-priority fix.

PR-body checkboxes

Held off flipping the seven manual-test boxes since F1 requires editing the PR body itself; flipping them now would force a rebase-merge to preserve them. All seven are pre-validated above and will flip in round 2.

Disposition

QA Failed applied as the final act. Once F1 is addressed, the Ready for QA label will be re-applied automatically by the existing label workflow on the next push (or by Dev manually after a body-only edit).

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying QA Failed — single substantive finding (F1 above) is doc drift in PR body / commit message about new-test counts (claims 20 new / 12 invalid / was 43; actual 23 new / 13 invalid / was 40). PR-body edit only, no code change required. Code, regex, validator placement, click wiring, scope vs. issue #40 ACs, local stack (577 pass, 96.14%, ruff/mypy clean), and CI (12/12 required green incl. vdsm) are all good. Smoke confirms all four issue-#40 AC inputs rejected with the expected actionable message + exit 1. PR-body checkboxes held off until F1 fix lands.

@cmeans cmeans added QA Failed QA found issues — needs dev attention and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

F1 addressed via PR-body-only edit (no code change required, per QA's suggestion):

  • 20 new test cases23 new test cases
  • 12 invalid inputs rejected13 invalid inputs rejected (and called out the additional 1.0.0 --extra, ==1.2.3, path-traversal probes that the count was undercounting)
  • (was 43)(was 40) in the test plan checkbox

Commit 7841891's message carries the same drift but is left as-is — amending it would force a rebase-merge and lose the QA approval state, and the PR body is the canonical user-facing description.

Toggling QA FailedReady for QA.

@cmeans-claude-dev cmeans-claude-dev Bot added Ready for QA Dev work complete — QA can begin review and removed QA Failed QA found issues — needs dev attention labels May 1, 2026
@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA Round 2 — PASS

F1 closed via PR body edit (no code change, head still 1f1234d):

  • "23 new test cases" (was "Twenty")
  • "8 valid versions accepted, 13 invalid inputs rejected" (was 12)
  • "all 63 tests pass (was 40)" (was "(was 43)")
  • Bonus: the parenthetical now explicitly calls out the arg-injection probes (1.0.0 --extra, ==1.2.3) and the path-traversal payload, so the security-relevant inputs aren't lost in the count.

Re-verified on the unchanged head per feedback_verification_before_signoff_label:

  • uv run pytest → 577 passed, 100 deselected (= integration+vdsm, run separately), 96.14% coverage. Matches CHANGELOG entry verbatim.
  • uv run ruff check src/ tests/ clean.
  • uv run ruff format --check src/ tests/ — 69 files already formatted.
  • uv run mypy src/ — no issues, 28 source files.
  • CI: 12/12 required green incl. vdsm integration tests SUCCESS (CI from round 1 still applies — no new push, same SHA).

Manual smoke from round 1 still applies (same code): all four issue-#40 AC inputs (latest, 1.2, , 1.0.0; whatever) plus arg-injection (1.0.0 --extra) and v-prefix (v1.2.3) all rejected with the actionable Error: Invalid version string: '<X>'. Expected MAJOR.MINOR.PATCH ... + exit 1. Valid --revert 0.5.1 (current) hits the no-op short-circuit.

PR-body manual-test boxes 1–7 flipped now that the body is final. Ready for QA Signoff applied as the final act. Awaiting maintainer's QA Approved.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying Ready for QA Signoff — F1 (the only round-1 finding) closed via PR body edit. All 12 required CI checks green incl. vdsm. Local re-verified on unchanged head 1f1234d: 577 pass, 96.14% coverage, ruff/format/mypy clean. PR-body checkboxes 1–7 flipped (all pre-validated in round 1; body is now final).

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Approved Manual QA testing completed and passed and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Signoff QA passed — ready for maintainer final review and merge labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit 3f24334 into main May 1, 2026
62 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the fix/version-string-validation branch May 1, 2026 17:37
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
Pre-rebase pytest run reported 558/96.18%; actual on the rebased
branch (post-#83 merge into main) is 581/96.19% — the 23-test
delta is PR #83's contribution that landed mid-flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
## Summary

Closes #84.

Same root-cause family as #68 (delete + getinfo). DSM 7.x's
`SYNO.FileStation.CopyMove start` does **not** honor the documented
comma-joined multi-path format on v2 — a request with `path=/a,/b` is
treated as a single literal path and silently no-ops while the task
reports success. Symptom on `move_files(paths=[a, b])`: tool returns
`Moved 2 item(s) ... Source files have been removed.` while no files
actually move on disk. Same shape on `copy_files`.

Single-path moves worked in v0.5.1 because the comma-join is never
constructed for a one-element list. The multi-path code path was never
exercised by an integration test that asserted *both* source and
destination state, so the silent-no-op slipped past the existing
dest-only check.

## Why this slipped through (and what changed in coverage)

- No multi-path move or copy integration test existed.
`test_06_move_folder` did one single-path call; the post-#68
`TestMultiPathDelete` was added for delete only.
- `test_07_verify_move` only asserted the file appeared at the
destination — the source-side check was a comment, not an assertion. A
silent no-op that returned a success string would have passed.

This PR adds the missing multi-path tests for both move and copy and
strengthens the single-path test to also assert source-side state. Going
forward, any state-mutating integration test should follow the
source-AND-dest pattern so silent-no-op regressions can't return.

## What changed

- **Code:** Extracted `_copy_move_one_path` (mirroring
`_delete_one_path` from #77) and wrapped `_copy_move` in a per-path
loop. `escape_multi_path` is no longer called there.
`restore_from_recycle_bin` routes through `_copy_move` for its
multi-path branch, so it inherits the fix automatically. Added a
defensive empty-list guard at the top of `_copy_move` so an empty
`paths` list short-circuits with a clear error instead of returning a
misleading "0 item(s)" success message.
- **Pinned to v2** kept — same DSM v3 JSON-format issue as #77.
- **Aggregated `processed_size`** across per-path tasks so the response
keeps reporting the total byte count.

## Tests

**Unit (4 new):**
- `TestCopyFiles::test_multipath_uses_per_path_serial_calls` — N
CopyMove `start` requests for N paths, each carrying a single path (no
commas), all pinned to v2, aggregated response covers every input file.
- `TestMoveFiles::test_multipath_uses_per_path_serial_calls` — same
shape with `remove_src=true` asserted per-call.
- `test_copy_empty_paths_returns_not_found` /
`test_move_empty_paths_returns_not_found` — defensive guard added with
the refactor.

**Integration (6 new, also re-exported in
`tests/vdsm/test_vdsm_integration.py`):**
- `TestMultiPathMove` (3 cases): create two folders → multi-path move →
assert BOTH at dest AND both gone from source.
- `TestMultiPathCopy` (3 cases): same shape but assert BOTH at dest AND
both still at source (the copy invariant).

**Strengthened (1):**
- `TestWriteOperations::test_07_verify_move` — previously-soft "should
not be in this listing" comment converted to `assert "original/" not in
src_listing` so silent-no-op moves can't slip past dest-only checks.

581 unit tests pass at 96.19% coverage on the rebased branch (post-#83
merge into main). ruff/mypy clean.

## Test plan

- [x] `uv run pytest tests/modules/filestation/test_operations.py -v` —
33 pass (was 29)
- [x] `uv run pytest` — full unit suite green (581 passed, 112
deselected, 96.19%)
- [x] `uv run ruff check src/ tests/`
- [x] `uv run ruff format --check src/ tests/`
- [x] `uv run mypy src/`
- [x] vdsm CI runs `TestMultiPathMove` + `TestMultiPathCopy` against DSM
7.2.2 and they pass
- [ ] Smoke against real NAS (post-merge): two-file `move_files`
actually moves both files; `get_file_info` confirms files at dest with
non-zero size and absent at source

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request May 1, 2026
5 tasks
cmeans-claude-dev Bot added a commit that referenced this pull request May 2, 2026
## Summary

Cuts v0.5.2, shipping six PRs that landed since v0.5.1 (2026-05-01):

- **#79** mcp-publisher v1.5.0 → v1.7.6 (registry OIDC audience fix)
- **#80** keyring exception handler narrowing (closes #38)
- **#81** bg update-check executor timeout + log (closes #39)
- **#82** pygments 2.19.2 → 2.20.0 (GHSA-5239-wwwm-4pmq, ReDoS)
- **#83** `--revert` version-string validation (closes #40)
- **#85** per-path serial for `move_files` + `copy_files` (closes #84)

## Why now

Two recent bug fixes (#83, #85) are user-visible enough to warrant
shipping, and #84 in particular is a confusing silent-no-op regression
on multi-file moves — getting that to PyPI promptly matters. The four
post-0.5.1 quality fixes (#79#82) are stacked behind it.

This release also exercises **#79's mcp-publisher v1.7.6 pin
end-to-end** so the registry can catch up to current. v0.5.1's registry
entry is missing because #79 landed AFTER the v0.5.1 tag-push, and
`actions/checkout@v6` resolved to the tag's commit on re-runs of the
failed `publish-registry` job — the fix wasn't picked up. The v0.5.2 tag
will pull the correct pin from main.

## State after merge

Bug-labeled issue queue is empty. The structural multi-path-serial fix
family (delete + getinfo + move + copy + restore) is now complete on
real DSM 7.x — every File Station write tool that takes a `paths:
list[str]` issues one DSM task per path, sidestepping the
comma-joined-multipath quirk that #68 and #84 each surfaced.

## Files changed

- `pyproject.toml` — version 0.5.1 → 0.5.2
- `server.json` — auto-synced via `python scripts/sync-server-json.py`
- `uv.lock` — refreshed via `uv lock`
- `CHANGELOG.md` — `## Unreleased` (with the six entries above) renamed
to `## 0.5.2 (2026-05-01)`, fresh empty `## Unreleased` inserted above
it for the next cycle

## Test plan

- [x] CI green on this branch (lint, typecheck, test 3.11/3.12/3.13,
vdsm integration tests, version-sync, validate-server-json)
- [ ] After merge: tag `v0.5.2` push fires `publish.yml`; PyPI publish
succeeds
- [ ] After merge: `publish-registry` job succeeds end-to-end (this is
the validation point for #79's fix — the failure mode in v0.5.1 was
`invalid audience: expected https://registry.modelcontextprotocol.io,
got [mcp-registry]`)
- [ ] After tag: `mcp-synology --check-update` from a v0.5.1 install
reports v0.5.2 available; `uv tool install mcp-synology@latest` upgrades
cleanly
- [ ] Smoke (post-install): two-file `move_files` actually moves both
files (the #84 regression scenario)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate version strings passed to --auto-upgrade and --revert

2 participants