fix(cli): validate version strings on --revert before pip handoff#83
Conversation
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>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
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, raisesclick.ClickExceptionwith format hint, runs once at the chokepoint inside_do_revertafterprevis resolved from either source — so both--revert <VER>and corrupt-state paths are covered with one call site.--auto-upgradeexclusion holds. ClickChoice(["enable", "disable"])filters at parse time;_do_auto_upgradeshellsmcp-synology@latest(uv) orpipx upgrade mcp-synology— neither path takes user-supplied version input.state["previous_version"] = currentwrites the result ofimportlib.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 bygrep -rn "revert\|_validate_version_string" src/— main.py:78 is the only ingress), unit tests cover all four AC exampleslatest/1.2//1.0.0; whatever(✔), CHANGELOG### Fixedentry under## Unreleased(✔). - Local stack on
1f1234d:uv run pytestreports 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 testsSUCCESS. - Smoke against the installed CLI (
uv run mcp-synology --revert <X>), all returningError: 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-rc1passes the validator and reaches the existing "Cannot detect package manager" branch (becauseuv runisn't auv 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
## Testssection) - 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).
|
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. |
|
F1 addressed via PR-body-only edit (no code change required, per QA's suggestion):
Commit Toggling QA Failed → Ready for QA. |
cmeans
left a comment
There was a problem hiding this comment.
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 testsSUCCESS (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.
|
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). |
## 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>
## 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>
Summary
Closes #40.
cli/version.py::_do_revertpreviously fedtarget_version(or theprevious_versionloaded from~/.local/state/mcp-synology/global.yaml) straight intosubprocess.run(["uv", "tool", "install", "--force", f"mcp-synology=={prev}"]).shell=Falsealready 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-corruptedprevious_versionfield in the state file would propagate the same garbage on a no-arg--revert.What changed
_validate_version_string()helper applies a loose PEP 440-ish regex (^\d+\.\d+\.\d+([-.]?[a-zA-Z0-9]+)*$) that accepts0.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 raisesclick.ClickExceptionso click renders the standardError: ...line and exits 1 — the same shape as the other CLI error paths — and the message names the expected format._do_revert(afterprevis 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-upgradeis aclick.Choice(["enable", "disable"])— click rejects anything else before the value reaches our code. And the_do_auto_upgradeupgrade target is a literalmcp-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 like1.0.0 --extraand==1.2.3and a path-traversal payload).TestDoRevert: two regression tests verifying that an invalid explicit--revert <VER>and a corrupt state-fileprevious_versionboth raiseClickExceptionand never reachsubprocess.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 greenuv run ruff check src/ tests/uv run ruff format --check src/ tests/uv run mypy src/mcp-synology --revert latest→Error: Invalid version string: 'latest'. Expected MAJOR.MINOR.PATCH ...+ exit 1mcp-synology --revert 0.5.0still works as before (mocked subprocess in tests; real-world path unchanged)🤖 Generated with Claude Code