diff --git a/README.md b/README.md index 5ccddbd..4242e15 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ Cache management functions are available at the module level and on the `Client` import musher info = musher.cache_info() # cache statistics -musher.cache_remove("myorg/my-bundle:1.0.0") # remove a specific bundle -musher.cache_clean() # remove expired entries +musher.cache_remove("myorg/my-bundle:1.0.0") # remove cached metadata for a bundle version +musher.cache_clean() # reclaim expired entries and unreferenced blobs musher.cache_clear() # remove all cached data path = musher.cache_path() # cache directory path ``` diff --git a/docs/configuration.md b/docs/configuration.md index d9d9622..7a6265b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,7 +16,9 @@ The SDK uses platform-aware directory resolution with the following precedence: | config | `MUSHER_CONFIG_HOME` | `~/.config/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\config` | | data | `MUSHER_DATA_HOME` | `~/.local/share/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\data` | | state | `MUSHER_STATE_HOME` | `~/.local/state/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\state` | -| runtime | `MUSHER_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/musher` | `~/Library/Caches/TemporaryItems/musher` | `%LOCALAPPDATA%\musher\runtime` | +| runtime | `MUSHER_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/musher` | `/musher/run`* | `\musher\run`* | + +\* `` is the system temporary directory (e.g. `/tmp` on macOS, `C:\Users\\AppData\Local\Temp` on Windows). On Windows, the SDK uses a flat layout under `%LOCALAPPDATA%\musher\` with category subdirectories rather than relying on `platformdirs`, which maps some categories to the same physical path. diff --git a/src/musher/__init__.py b/src/musher/__init__.py index 1350d24..8f9fbaa 100644 --- a/src/musher/__init__.py +++ b/src/musher/__init__.py @@ -23,7 +23,6 @@ MusherError, RateLimitError, RegistryError, - VersionNotFoundError, ) from musher._export import ClaudePluginExport, OpenAIInlineSkill, OpenAILocalSkill from musher._handles import ( @@ -81,7 +80,6 @@ "ResolveResult", "SkillHandle", "ToolsetHandle", - "VersionNotFoundError", "__version__", "cache_clean", "cache_clear", diff --git a/src/musher/_bundle.py b/src/musher/_bundle.py index a9cb63c..dd6043c 100644 --- a/src/musher/_bundle.py +++ b/src/musher/_bundle.py @@ -138,6 +138,8 @@ def _place_skill_file( candidate_root = str(PurePosixPath(*parts[:i])) if f"{candidate_root}/SKILL.md" in assets: rel = str(PurePosixPath(*parts[i:])) + if ".." in PurePosixPath(rel).parts: + return skill_roots.setdefault(candidate_root, {})[rel] = file_handles[asset.logical_path] return # Top-level skill file without nested directory diff --git a/src/musher/_cache.py b/src/musher/_cache.py index c54d510..d191c52 100644 --- a/src/musher/_cache.py +++ b/src/musher/_cache.py @@ -365,6 +365,7 @@ def clear(self) -> None: """Remove all cached data.""" if self._cache_dir.is_dir(): shutil.rmtree(self._cache_dir) + self._tag_written = False # ── Internal ─────────────────────────────────────────────────── diff --git a/src/musher/_client.py b/src/musher/_client.py index 9d8ef4d..a502c53 100644 --- a/src/musher/_client.py +++ b/src/musher/_client.py @@ -189,6 +189,10 @@ async def pull(self, ref: str) -> Bundle: for layer in result.manifest.layers: cached_blob = self._cache.get_blob(layer.content_sha256) if cached_blob is not None: + if self._config.verify_checksums: + actual_sha = hashlib.sha256(cached_blob).hexdigest() + if actual_sha != layer.content_sha256: + raise IntegrityError(expected=layer.content_sha256, actual=actual_sha) assets[layer.logical_path] = Asset( asset_id=layer.asset_id, logical_path=layer.logical_path, @@ -233,14 +237,13 @@ def _build_assets_from_pull( content = str(item.get("contentText", "")).encode() layer = layer_map.get(logical_path) - # Verify checksum against the resolve manifest - if layer and self._config.verify_checksums: - actual_sha = hashlib.sha256(content).hexdigest() - if actual_sha != layer.content_sha256: - raise IntegrityError(expected=layer.content_sha256, actual=actual_sha) + # Always compute actual hash — never cache under an unverified claimed hash + actual_sha = hashlib.sha256(content).hexdigest() - content_sha256 = layer.content_sha256 if layer else hashlib.sha256(content).hexdigest() - self._cache.put_blob(content_sha256, content) + if layer and self._config.verify_checksums and actual_sha != layer.content_sha256: + raise IntegrityError(expected=layer.content_sha256, actual=actual_sha) + + self._cache.put_blob(actual_sha, content) media_type = str(item.get("mediaType") or "") or (layer.media_type if layer else None) assets[logical_path] = Asset( @@ -248,10 +251,19 @@ def _build_assets_from_pull( logical_path=logical_path, asset_type=AssetType(str(item["assetType"])), content=content, - content_sha256=content_sha256, + content_sha256=actual_sha, size_bytes=layer.size_bytes if layer else len(content), media_type=media_type or None, ) + + # Enforce manifest completeness — all expected layers must be present + missing = set(layer_map.keys()) - set(assets.keys()) + if missing: + raise IntegrityError( + expected=f"all {len(layer_map)} manifest layers", + actual=f"missing {len(missing)} layers: {', '.join(sorted(missing))}", + ) + return assets async def _pull_version(self, namespace: str, slug: str, version: str) -> dict[str, object]: diff --git a/src/musher/_handles.py b/src/musher/_handles.py index b4c4856..95785d3 100644 --- a/src/musher/_handles.py +++ b/src/musher/_handles.py @@ -8,12 +8,20 @@ import tempfile import zipfile from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import cast from musher._export import ClaudePluginExport, OpenAIInlineSkill, OpenAILocalSkill +def _validate_relative_path(relative_path: str) -> None: + """Reject paths that could escape the target directory.""" + p = PurePosixPath(relative_path) + if p.is_absolute() or ".." in p.parts: + msg = f"Unsafe relative path in skill: {relative_path}" + raise ValueError(msg) + + @dataclass(frozen=True, slots=True) class FileHandle: """Typed handle to a single file within a bundle.""" @@ -66,6 +74,7 @@ def export_openai_inline_skill(self) -> OpenAIInlineSkill: buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for relative_path, fh in self._files.items(): + _validate_relative_path(relative_path) zf.writestr(f"{self.name}/{relative_path}", fh.bytes()) content_b64 = base64.b64encode(buf.getvalue()).decode("ascii") return OpenAIInlineSkill( @@ -81,6 +90,7 @@ def export_path(self, dest: Path | None = None) -> Path: skill_dir = dest / self.name for relative_path, fh in self._files.items(): + _validate_relative_path(relative_path) out = skill_dir / relative_path out.parent.mkdir(parents=True, exist_ok=True) _ = out.write_bytes(fh.bytes()) @@ -95,6 +105,7 @@ def export_zip(self, dest: Path | None = None) -> Path: zip_path = dest / f"{self.name}.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for relative_path, fh in self._files.items(): + _validate_relative_path(relative_path) zf.writestr(f"{self.name}/{relative_path}", fh.bytes()) return zip_path diff --git a/src/musher/_http.py b/src/musher/_http.py index 65eed19..c64c3a1 100644 --- a/src/musher/_http.py +++ b/src/musher/_http.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING import httpx @@ -63,6 +65,19 @@ async def close(self) -> None: self._client = None +def _parse_retry_after(header: str) -> float | None: + """Parse Retry-After as delay-seconds or HTTP-date (RFC 9110).""" + try: + return float(header) + except ValueError: + pass + try: + dt = parsedate_to_datetime(header) + return max(0.0, (dt - datetime.now(tz=UTC)).total_seconds()) + except (ValueError, TypeError): + return None + + def _raise_for_status(response: httpx.Response) -> None: """Map HTTP error responses to SDK exceptions.""" if response.is_success: @@ -78,7 +93,9 @@ def _raise_for_status(response: httpx.Response) -> None: if status == 429: # noqa: PLR2004 retry_after_header: str | None = response.headers.get("Retry-After") # pyright: ignore[reportAny] - raise RateLimitError(retry_after=float(retry_after_header) if retry_after_header else None) + raise RateLimitError( + retry_after=_parse_retry_after(retry_after_header) if retry_after_header else None + ) # Try RFC 9457 Problem Details try: diff --git a/tests/test_cache.py b/tests/test_cache.py index bf582da..b9be1d4 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -238,6 +238,18 @@ def test_clear_noop_if_missing(self, tmp_path: Path): cache = BundleCache(cache_dir=tmp_path / "nonexistent") cache.clear() # should not raise + def test_clear_then_write_recreates_cachedir_tag(self, tmp_path: Path): + cache = BundleCache(cache_dir=tmp_path) + cache.put_blob("ab" * 32, b"data") + tag = tmp_path / "CACHEDIR.TAG" + assert tag.is_file() + cache.clear() + assert not tag.exists() + # Write again on the same instance — tag must be recreated + cache.put_blob("cd" * 32, b"data2") + assert tag.is_file() + assert tag.read_text().startswith("Signature: 8a477f597d28d172789f06886806bc55") + class TestBlobOverwrite: def test_blob_overwrite_succeeds(self, tmp_path: Path): diff --git a/tests/test_errors.py b/tests/test_errors.py index c757c77..07b3344 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -9,8 +9,8 @@ MusherError, RateLimitError, RegistryError, - VersionNotFoundError, ) +from musher._errors import VersionNotFoundError class TestHierarchy: diff --git a/tests/test_handles.py b/tests/test_handles.py index 0a28b18..bf83f87 100644 --- a/tests/test_handles.py +++ b/tests/test_handles.py @@ -145,6 +145,48 @@ def test_export_zip_default_dest(self): assert result.is_file() +class TestSkillHandlePathTraversal: + """Verify that path traversal attempts are rejected in all export methods.""" + + def _make_malicious_skill(self) -> SkillHandle: + files = { + "SKILL.md": FileHandle(logical_path="skills/evil/SKILL.md", _content=b"# Evil"), + "../../etc/passwd": FileHandle( + logical_path="skills/evil/../../etc/passwd", _content=b"root:x:0:0" + ), + } + return SkillHandle( + name="evil", + description="Malicious skill", + root_path="skills/evil", + _files=files, + ) + + def test_export_path_rejects_traversal(self, tmp_path): + skill = self._make_malicious_skill() + with pytest.raises(ValueError, match="Unsafe relative path"): + skill.export_path(dest=tmp_path) + + def test_export_zip_rejects_traversal(self, tmp_path): + skill = self._make_malicious_skill() + with pytest.raises(ValueError, match="Unsafe relative path"): + skill.export_zip(dest=tmp_path) + + def test_export_openai_inline_rejects_traversal(self): + skill = self._make_malicious_skill() + with pytest.raises(ValueError, match="Unsafe relative path"): + skill.export_openai_inline_skill() + + def test_absolute_path_rejected(self, tmp_path): + files = { + "SKILL.md": FileHandle(logical_path="skills/x/SKILL.md", _content=b"# X"), + "/etc/passwd": FileHandle(logical_path="/etc/passwd", _content=b"root"), + } + skill = SkillHandle(name="x", description="X", root_path="skills/x", _files=files) + with pytest.raises(ValueError, match="Unsafe relative path"): + skill.export_path(dest=tmp_path) + + class TestPromptHandle: def test_text_delegates(self): fh = FileHandle(logical_path="prompts/main.txt", _content=b"Be helpful.") diff --git a/tests/test_http.py b/tests/test_http.py index 0a6eace..66833ce 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -102,6 +102,42 @@ async def test_500_plain_text_fallback(self, transport: HTTPTransport): await transport.get("/v1/test") assert exc_info.value.status == 500 + @respx.mock + async def test_429_with_http_date_retry_after(self, transport: HTTPTransport): + respx.get("https://api.test.dev/v1/test").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "Mon, 30 Mar 2026 12:00:00 GMT"}, + text="Too Many Requests", + ) + ) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/v1/test") + assert exc_info.value.retry_after is not None + assert isinstance(exc_info.value.retry_after, float) + + @respx.mock + async def test_429_with_invalid_retry_after(self, transport: HTTPTransport): + respx.get("https://api.test.dev/v1/test").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "not-a-date-or-number"}, + text="Too Many Requests", + ) + ) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/v1/test") + assert exc_info.value.retry_after is None + + @respx.mock + async def test_429_without_retry_after(self, transport: HTTPTransport): + respx.get("https://api.test.dev/v1/test").mock( + return_value=httpx.Response(429, text="Too Many Requests") + ) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/v1/test") + assert exc_info.value.retry_after is None + class TestClose: @respx.mock