You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: give the fifteen hand-built album paths the guard resolve_path has (#534)
Extracts the guarded album-path core into handlers/_shared._album_dir, beside
_normalize_slug and _is_path_confined, so the helper a caller reaches for is the
one carrying the guards. Replaces fifteen hand-built album paths across six
handler modules; _genre_dir/_albums_dir cover the two truncations.
Only resolve_path ever applied the resolved confinement check, so confine is a
per-call-site decision tracking each site's prior behaviour — True at
resolve_path, False at the sites operating on an album directory that already
exists, which may legitimately be a symlink pointing outside its root
(test_symlinked_audio_dir_passes). The lexical traversal guard always applies.
_album_dir raises, and each call site reports the failure per its own contract:
_resolve_audio_dir returns its (error_json, None) tuple, update_album_status
adds a release_issues entry, and migrate_audio_layout records a per-album skip
so one unresolvable album cannot discard the report of files already moved.
resolve_path's output is unchanged except one deliberate tightening: genre='..'
is now rejected rather than returning …/albums/../al.
Adds 30 tests across tests/unit/handlers/test_album_paths.py and
test_album_path_call_sites.py.
Co-Authored-By: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,6 +10,10 @@ This project uses [Conventional Commits](https://conventionalcommits.org/) and [
10
10
- **The lint/type-check toolchain is pinned exactly instead of floating** ([#532](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/532)) — `requirements.txt` pinned all 16 runtime deps with `==`, but every entry in `requirements-test.txt` used `>=`, so `ruff`, `mypy` and `bandit` resolved to whatever was newest on PyPI at the moment CI ran. Those three decide the Lint and Security Scan verdicts, and unlike a test runner they change their answer on unchanged code — a new rule or a widened check reddens a commit nobody touched, and re-running an old green build no longer reproduces it. The drift was already visible: the file read `ruff>=0.15.21` while CI had been installing `0.16.0`, which is why Dependabot closed #522 as redundant. `ruff`, `mypy` and `bandit` are now `==` pins (`cache: 'pip'` never mitigated this — it caches wheels, but pip still resolves to newest). The `pytest` stack stays on `>=`: it changes what runs, not what counts as a violation. A parametrized test in `tests/unit/shared/test_pinned_dependencies.py` keeps the three from silently loosening again.
11
11
12
12
### Fixed
13
+
- **The album directory shape is written down once, and the callers that built it by hand now get the traversal guard** — `#529` removed `tools/shared/paths.py` on the grounds that it was the *unguarded* variant of path resolution and "the obvious helper for a contributor to reach for", leaving `handlers/core.py:resolve_path` as the single implementation. That diagnosis was right, but the duplication it was meant to prevent was already there and survived the removal: `Path(root) / "artists" / artist / "albums" / genre / slug` and its two truncations appeared at fifteen sites across six handler modules. The reason is structural rather than carelessness — `resolve_path` is an `async` MCP tool that returns a JSON string, so no library code can call it, and every caller that needed a path had no option but to respell it. Only `resolve_path` applied `_normalize_slug` and a confinement check; the rest interpolated straight in, which is the same weakness `#529` deleted a file over. The guarded core is now `handlers/_shared._album_dir`, living beside `_normalize_slug` and `_is_path_confined` so the helper a caller reaches for is the one that carries the guards, with `_genre_dir`/`_albums_dir` for the two truncations callers wanted. Still one implementation — just a callable one.
14
+
- Two guards, not one, and they are not the same guard. A **lexical** pass always runs: it rejects a `..` or separator-carrying `artist`, `genre` or `subdir`*before* the layout is rendered, and rejects an absolute segment outright rather than silently relativizing it into a confined-but-wrong path. A **resolved** confinement check (`resolved.is_relative_to(root)`) is separate, because it is not a traversal guard — it also rejects a symlinked album directory, which is a *supported* layout (`test_symlinked_audio_dir_passes`). Only `resolve_path` ever applied it, so each call site passes the `confine` value matching what it did before centralising: `True` at `resolve_path`, `False` at the sites that operate on an album directory which already exists (`_resolve_audio_dir`, `update_album_status`, `migrate_audio_layout`, `rename_album`, `validate_album_structure`). It defaults to `True` so a *new* caller who forgets fails closed and loudly.
15
+
-**`resolve_path`'s output is unchanged except for one deliberate tightening:** a `genre` of `..` used to return `…/albums/../al`, and is now rejected with the same `Resolved path escapes root directory` error every other escape returns. Every other input, including `genre="/etc"`, returns exactly what it did before.
16
+
-`_album_dir` raises, so each call site catches it and reports the failure the way its own contract says: `_resolve_audio_dir` returns its `(error_json, None)` tuple rather than raising past the twenty-two call sites that branch on it; `update_album_status` adds a `release_issues` entry rather than collapsing the aggregated gate into one opaque error; and `migrate_audio_layout` records a per-album skip, so one unresolvable album cannot discard the report of files it has already physically moved into `originals/`.
13
17
- **`tracks_completed` no longer flips between two different numbers depending on which code path last ran** ([#523](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/523)) — `reference/state-schema.md` defines the field as "Number of tracks with completed status", but only the incremental track-change path counted the track files. A full rebuild (`scan_albums`) and the incremental README-changed branch both counted the album README's `## Tracklist` table instead. Since `update_track_field` rewrites a track file without touching that table, the two drifted the moment a track's status changed: `rebuild_state()` — the documented remedy for a stale cache — discarded the correct count and reinstated the README's stale one, and editing only the README silently reset it. This also made `list_albums` and `get_album_progress` disagree about the same album, and made the CLI print a README-derived numerator over an actual-file denominator (a finished album could render as `[0/12 tracks]`). All three sites now derive the count from the track files through a single `_count_completed_tracks` helper. `parse_album_readme` still reports what the README table claims, but nothing feeds it into state.
14
18
-**A corrupt `ideas` or `skills` section in state.json no longer crashes `python -m tools.state update`** ([#525](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/525)) — `incremental_update` type-guards its top-level state sections so a wrong-typed one returns `None` and the caller falls back to a full rebuild (the `#393` contract), but the guard only covered `config` and `albums`. The function also does `state['ideas'].get('file_mtime')` and `state['skills'].get('skills_root')`, so a non-mapping value in either raised `AttributeError` straight past `cmd_update`'s `is None` fallback and aborted the CLI with a traceback — the exact failure the guard exists to prevent. All four sections are now guarded; both are re-derived from disk on a rebuild, so falling back loses nothing.
15
19
-**`.gitignore` no longer lists `TESTING.md` as a build artifact** ([#526](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/526)) — the entry sat in the "Build artifacts" block between `*.egg-info/` and `.coverage`, but `TESTING.md` is a tracked contributor doc referenced from `skills/test/test-definitions.md`. The rule was inert (`.gitignore` does not affect already-tracked files) but would have silently dropped the doc for anyone who ran `git rm --cached` or re-added it after a delete.
0 commit comments