mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 03:56:53 +03:00
fix: relocate binary analysis skill
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"""Cache management for project analysis data.
|
||||
|
||||
Provides atomic cache read/write operations using the atomic write utility.
|
||||
Cached data is stored in the project's cache/ directory as JSON files.
|
||||
|
||||
Key guarantees:
|
||||
- All cache writes use atomic_write_json (tempfile + os.rename).
|
||||
- Cache cleanup (clean command) removes all cache files atomically.
|
||||
- Cache keys are validated to prevent path traversal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from binary_analysis.projects.atomic import atomic_write_json
|
||||
|
||||
# Cache subdirectory within a project workspace
|
||||
CACHE_DIRNAME = "cache"
|
||||
|
||||
# Valid characters for cache keys (alphanumeric, underscore, hyphen, dot)
|
||||
_VALID_KEY_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.")
|
||||
|
||||
|
||||
def _validate_cache_key(key: str) -> str:
|
||||
"""Validate a cache key to prevent path traversal and invalid chars.
|
||||
|
||||
Args:
|
||||
key: The cache key to validate.
|
||||
|
||||
Returns:
|
||||
The validated key (unchanged if valid).
|
||||
|
||||
Raises:
|
||||
ValueError: If the key is invalid.
|
||||
"""
|
||||
if not key or not key.strip():
|
||||
raise ValueError("Cache key must not be empty")
|
||||
|
||||
key = key.strip()
|
||||
|
||||
if "\x00" in key:
|
||||
raise ValueError("Cache key must not contain null bytes")
|
||||
|
||||
if "/" in key or "\\" in key:
|
||||
raise ValueError("Cache key must not contain path separators")
|
||||
|
||||
if key.startswith("."):
|
||||
raise ValueError("Cache key must not start with a dot")
|
||||
|
||||
invalid_chars = [c for c in key if c not in _VALID_KEY_CHARS]
|
||||
if invalid_chars:
|
||||
raise ValueError(f"Cache key contains invalid characters: {''.join(invalid_chars)}")
|
||||
|
||||
if not key.endswith(".json"):
|
||||
key = key + ".json"
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def _cache_path(project_path: str, key: str) -> str:
|
||||
"""Resolve the full path for a cache entry.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
key: Validated cache key.
|
||||
|
||||
Returns:
|
||||
Full path to the cache file.
|
||||
"""
|
||||
return os.path.join(project_path, CACHE_DIRNAME, key)
|
||||
|
||||
|
||||
def cache_get(project_path: str, key: str) -> Any:
|
||||
"""Retrieve a cached value.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
key: Cache key (must be a safe filename).
|
||||
|
||||
Returns:
|
||||
The cached data, or None if the key doesn't exist or is corrupted.
|
||||
|
||||
Raises:
|
||||
ValueError: If the cache key is invalid.
|
||||
"""
|
||||
key = _validate_cache_key(key)
|
||||
cache_file = _cache_path(project_path, key)
|
||||
|
||||
if not os.path.exists(cache_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Corrupted cache entry — return None so caller can regenerate
|
||||
return None
|
||||
|
||||
|
||||
def cache_set(project_path: str, key: str, value: Any) -> None:
|
||||
"""Atomically store a value in the cache.
|
||||
|
||||
Uses atomic_write_json to ensure cache entries are never partially
|
||||
written. Invalid or non-serializable values raise before any file is
|
||||
touched.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
key: Cache key (must be a safe filename).
|
||||
value: JSON-serializable value to cache.
|
||||
|
||||
Raises:
|
||||
ValueError: If the cache key is invalid.
|
||||
TypeError: If the value is not JSON-serializable.
|
||||
"""
|
||||
key = _validate_cache_key(key)
|
||||
cache_file = _cache_path(project_path, key)
|
||||
|
||||
# Ensure cache directory exists
|
||||
cache_dir = os.path.dirname(cache_file)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
# Serialize via JSON round-trip to validate types
|
||||
json_str = json.dumps(value, ensure_ascii=False)
|
||||
|
||||
# Atomic write
|
||||
atomic_write_json(cache_file, json.loads(json_str))
|
||||
|
||||
|
||||
def cache_delete(project_path: str, key: str) -> bool:
|
||||
"""Delete a cached entry.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
True if the entry was deleted, False if it didn't exist.
|
||||
|
||||
Raises:
|
||||
ValueError: If the cache key is invalid.
|
||||
"""
|
||||
key = _validate_cache_key(key)
|
||||
cache_file = _cache_path(project_path, key)
|
||||
|
||||
if not os.path.exists(cache_file):
|
||||
return False
|
||||
|
||||
try:
|
||||
os.unlink(cache_file)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cache_clear(project_path: str) -> int:
|
||||
"""Remove all cached entries for a project.
|
||||
|
||||
Deletes all files in the cache/ directory but does not remove
|
||||
the directory itself. Uses shutil.rmtree for efficiency, or
|
||||
individual deletes if that fails.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
|
||||
Returns:
|
||||
Number of cache entries removed.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
cache_dir = os.path.join(project_path, CACHE_DIRNAME)
|
||||
|
||||
if not os.path.exists(cache_dir):
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
try:
|
||||
# Count entries before clearing
|
||||
entries = [e for e in os.listdir(cache_dir) if os.path.isfile(os.path.join(cache_dir, e))]
|
||||
count = len(entries)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Remove all files and recreate empty directory
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
except OSError:
|
||||
# Fall back to individual deletes
|
||||
for entry in os.listdir(cache_dir):
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(os.path.join(cache_dir, entry))
|
||||
return count
|
||||
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return count
|
||||
|
||||
|
||||
def cache_list(project_path: str) -> list[str]:
|
||||
"""List all cached keys for a project.
|
||||
|
||||
Args:
|
||||
project_path: Absolute path to the project workspace directory.
|
||||
|
||||
Returns:
|
||||
Sorted list of cache keys (without .json extension).
|
||||
"""
|
||||
cache_dir = os.path.join(project_path, CACHE_DIRNAME)
|
||||
|
||||
if not os.path.exists(cache_dir):
|
||||
return []
|
||||
|
||||
keys: list[str] = []
|
||||
try:
|
||||
for entry in os.listdir(cache_dir):
|
||||
if entry.endswith(".json") and os.path.isfile(os.path.join(cache_dir, entry)):
|
||||
keys.append(entry[:-5]) # Remove .json
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return sorted(keys)
|
||||
Reference in New Issue
Block a user