fix: relocate binary analysis skill

This commit is contained in:
username
2026-07-31 13:25:08 -04:00
parent 0d4a3652e4
commit f66ed48b8c
107 changed files with 17 additions and 2 deletions
@@ -0,0 +1,786 @@
"""Project command — manage analysis workspaces.
Subcommands: create, list, status, clean, remove, migrate.
Implements the full project lifecycle with state machine enforcement,
atomic manifest writes, file-based locking, and confirmation gates for
destructive operations.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from typing import Any
from binary_analysis.cli.helpers import (
build_paginated_response,
clamp_page_size,
)
from binary_analysis.domain.enums import AuditResult, ProjectState
from binary_analysis.domain.errors import (
InvalidArgsError,
ProjectNotFoundError,
)
from binary_analysis.projects.cache import cache_clear
from binary_analysis.projects.lock import (
get_lock_holder,
is_locked,
)
from binary_analysis.projects.manifest import (
create_manifest,
load_manifest,
save_manifest,
update_manifest_field,
)
from binary_analysis.projects.state_machine import (
can_clean,
should_reject_migrate,
)
from binary_analysis.projects.workspace import (
create_workspace,
get_project_path,
get_workspace_subdirs,
list_workspaces,
remove_workspace,
validate_project_name,
workspace_exists,
)
from binary_analysis.reporting.audit import write_audit_event
# Current workspace version for migration
_WORKSPACE_VERSION = "1"
# ---------------------------------------------------------------------------
# Subparser registration
# ---------------------------------------------------------------------------
def _build_project_subparsers(subparsers: Any) -> None:
"""Register project sub-subcommands."""
create_parser: argparse.ArgumentParser = subparsers.add_parser(
"create", help="Create a new project workspace."
)
create_parser.add_argument("name", help="Project name.")
create_parser.add_argument(
"--dry-run",
action="store_true",
help="Preview creation without mutating.",
)
list_parser = subparsers.add_parser("list", help="List projects with pagination.")
# --limit is read from the root parser (consumed before the subparser by
# _extract_globals). The list subparser does not register its own --limit
# to avoid overwriting the root parser's value.
list_parser.add_argument(
"--page-token",
default=None,
help="Opaque pagination cursor from previous response (next_page_token).",
)
status_parser = subparsers.add_parser("status", help="Show project state and metadata.")
status_parser.add_argument("project", help="Project name or UUID.")
clean_parser = subparsers.add_parser("clean", help="Reset a FAILED project to CREATED.")
clean_parser.add_argument("project", help="Project name or UUID.")
clean_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt.")
clean_parser.add_argument(
"--force", action="store_true", help="Force clean without confirmation."
)
remove_parser = subparsers.add_parser("remove", help="Delete a project workspace.")
remove_parser.add_argument("project", help="Project name or UUID.")
remove_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt.")
remove_parser.add_argument(
"--force", action="store_true", help="Force removal without confirmation."
)
remove_parser.add_argument(
"--dry-run",
action="store_true",
help="Preview deletion paths without mutating.",
)
migrate_parser = subparsers.add_parser("migrate", help="Upgrade project workspace format.")
migrate_parser.add_argument("project", help="Project name or UUID.")
migrate_parser.add_argument(
"--plan",
action="store_true",
help="Show migration plan without mutating.",
)
migrate_parser.add_argument(
"--apply",
action="store_true",
help="Perform the workspace format upgrade.",
)
migrate_parser.add_argument(
"--dry-run",
action="store_true",
help="Preview migration plan without mutating.",
)
def add_subparser(subparsers: Any) -> argparse.ArgumentParser:
"""Register the project subcommand with sub-subcommands."""
parser: argparse.ArgumentParser = subparsers.add_parser(
"project",
help="Manage analysis workspaces.",
)
project_sub = parser.add_subparsers(dest="project_command", help="Project subcommands")
_build_project_subparsers(project_sub)
return parser
def _positive_int(value: str) -> int:
"""Validate a positive integer argument."""
try:
number = int(value)
except ValueError:
raise argparse.ArgumentTypeError("limit must be a positive integer") from None
if number <= 0:
raise argparse.ArgumentTypeError("limit must be a positive integer")
return number
# ---------------------------------------------------------------------------
# Command execution dispatch
# ---------------------------------------------------------------------------
def execute(args: argparse.Namespace) -> dict[str, Any]:
"""Run a project subcommand.
Dispatches to the appropriate handler based on project_command.
Returns a result dict compatible with the JSON envelope builder.
"""
subcommand = getattr(args, "project_command", None)
if subcommand is None:
raise InvalidArgsError(
"No project subcommand specified. "
"Available: create, list, status, clean, remove, migrate."
)
handlers: dict[str, Any] = {
"create": _execute_create,
"list": _execute_list,
"status": _execute_status,
"clean": _execute_clean,
"remove": _execute_remove,
"migrate": _execute_migrate,
}
handler = handlers.get(subcommand)
if handler is None:
raise InvalidArgsError(f"Unknown project subcommand: {subcommand}")
result: dict[str, Any] = handler(args)
return result
# ---------------------------------------------------------------------------
# Project path resolution
# ---------------------------------------------------------------------------
def _resolve_project_path(project_name: str) -> str:
"""Resolve a project name to its workspace path.
Also tries to resolve by UUID by scanning workspace directories.
Args:
project_name: Project name or UUID string.
Returns:
Absolute path to the project workspace directory.
Raises:
ProjectNotFoundError: If the project doesn't exist.
"""
# First, try by name
if workspace_exists(project_name):
return str(get_project_path(project_name))
# Try by UUID — scan all workspaces
for ws_name in list_workspaces():
ws_path = str(get_project_path(ws_name))
try:
manifest = load_manifest(ws_path)
if manifest.get("id") == project_name:
return ws_path
except Exception:
continue # Skip corrupted manifests
raise ProjectNotFoundError(project_name)
def _resolve_project_name(project_name_or_id: str) -> str:
"""Resolve a project name or UUID to the project's directory name.
Returns the directory name used in the workspace root.
"""
if workspace_exists(project_name_or_id):
return project_name_or_id
# Try UUID lookup
for ws_name in list_workspaces():
try:
ws_path = str(get_project_path(ws_name))
manifest = load_manifest(ws_path)
if manifest.get("id") == project_name_or_id:
return ws_name
except Exception:
continue
raise ProjectNotFoundError(project_name_or_id)
# ---------------------------------------------------------------------------
# Project create
# ---------------------------------------------------------------------------
def _execute_create(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project create' subcommand."""
t_start = time.perf_counter()
project_name = args.name
dry_run = getattr(args, "dry_run", False)
# Validate project name
try:
validate_project_name(project_name)
except ValueError as e:
raise InvalidArgsError(str(e)) from e
# Check for duplicates
if workspace_exists(project_name):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": f"Project '{project_name}' already exists.",
"category": "project",
}
],
"data": None,
}
# Dry-run: report plan without mutating
if dry_run:
project_dir_path = get_project_path(project_name)
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"dry_run": True,
"name": project_name,
"directory": str(project_dir_path),
"state": ProjectState.CREATED.value,
},
}
# Create workspace + manifest
project_dir_str = str(create_workspace(project_name))
manifest = create_manifest(project_name)
save_manifest(project_dir_str, manifest)
# Record audit event
duration_ms = int((time.perf_counter() - t_start) * 1000)
write_audit_event(
project_dir_str,
command="project create",
result=AuditResult.SUCCESS,
duration_ms=duration_ms,
args={"name": project_name},
project_id=manifest["id"],
)
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"id": manifest["id"],
"name": project_name,
"state": manifest["state"],
"created_at": manifest["created_at"],
},
}
# ---------------------------------------------------------------------------
# Project list
# ---------------------------------------------------------------------------
def _execute_list(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project list' subcommand with cursor-based pagination."""
# Read limit from global args (consumed by argparse before subparser).
limit, _clamp_warning = clamp_page_size(getattr(args, "limit", None))
page_token_str: str | None = getattr(args, "page_token", None)
# Build warnings
warnings: list[dict[str, Any]] = []
if _clamp_warning:
from binary_analysis.cli.helpers import make_warning
warnings.append(make_warning(_clamp_warning, severity="WARNING", category="pagination"))
# Collect all project names
all_names = list_workspaces()
# Decode cursor if present (opaque base64-encoded JSON with offset)
start_index = 0
if page_token_str:
try:
import base64
cursor_data = json.loads(base64.urlsafe_b64decode(page_token_str.encode("ascii")))
start_index = cursor_data.get("offset", 0)
except Exception:
raise InvalidArgsError("Invalid page_token value") from None
# Slice for pagination
total = len(all_names)
page_names = all_names[start_index : start_index + limit]
# Load manifests for each project in the page
items: list[dict[str, Any]] = []
for name in page_names:
try:
ws_path = str(get_project_path(name))
manifest = load_manifest(ws_path)
items.append(
{
"id": manifest.get("id"),
"name": manifest.get("name", name),
"state": manifest.get("state"),
"created_at": manifest.get("created_at"),
"binary_count": manifest.get("binary_count", 0),
"is_stale": manifest.get("is_stale", False),
}
)
except Exception:
# Skip corrupted/missing projects in listing
items.append(
{
"name": name,
"state": "UNKNOWN",
}
)
paginated = build_paginated_response(
items=items,
total=total,
offset=start_index,
limit=limit,
)
return {
"success": True,
"partial": False,
"warnings": warnings,
"diagnostics": [],
"data": paginated,
}
def _encode_cursor(data: dict[str, Any]) -> str:
"""Encode a cursor dict as a base64-encoded JSON string (opaque cursor)."""
import base64
json_bytes = json.dumps(data).encode("utf-8")
return base64.urlsafe_b64encode(json_bytes).decode("ascii")
def _decode_cursor(cursor_str: str) -> dict[str, Any]:
"""Decode a base64-encoded cursor string back to a dict."""
import base64
json_bytes = base64.urlsafe_b64decode(cursor_str.encode("ascii"))
result: dict[str, Any] = json.loads(json_bytes)
return result
# ---------------------------------------------------------------------------
# Project status
# ---------------------------------------------------------------------------
def _execute_status(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project status' subcommand."""
project_name = args.project
project_path = _resolve_project_path(project_name)
pw_name = _resolve_project_name(project_name)
manifest = load_manifest(project_path)
# Get lock information
lock_holder = get_lock_holder(project_path)
lock_info: dict[str, Any] | None = None
if lock_holder:
lock_info = {"holder": lock_holder}
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"id": manifest.get("id"),
"name": manifest.get("name", pw_name),
"state": manifest.get("state"),
"binary_count": manifest.get("binary_count", 0),
"created_at": manifest.get("created_at"),
"updated_at": manifest.get("updated_at"),
"workspace_version": manifest.get("workspace_version"),
"is_stale": manifest.get("is_stale", False),
"lock": lock_info,
"description": manifest.get("description"),
},
}
# ---------------------------------------------------------------------------
# Project clean
# ---------------------------------------------------------------------------
def _execute_clean(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project clean' subcommand.
Resets a FAILED project back to CREATED state, clearing cache and
diagnostics. Only operates on FAILED projects. Requires user
confirmation unless --yes or --force is provided.
"""
project_name = args.project
yes = getattr(args, "yes", False)
force = getattr(args, "force", False)
skip_confirmation = yes or force
project_path = _resolve_project_path(project_name)
manifest = load_manifest(project_path)
current_state_str = manifest.get("state", "")
try:
current_state = ProjectState(current_state_str)
except ValueError:
current_state = ProjectState.CREATED
# Confirmation check (VAL-PROJ-009: must come before state validation)
if not skip_confirmation:
try:
prompt = (
f"This will reset project '{project_name}' from FAILED to CREATED, "
f"clearing all cached data and diagnostics. Continue? [y/N]: "
)
print(prompt, file=sys.stderr, end="", flush=True)
response = sys.stdin.readline().strip().lower()
if response not in ("y", "yes"):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "INFO",
"message": "Clean operation cancelled by user.",
"category": "user",
}
],
"data": None,
}
except (EOFError, KeyboardInterrupt):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "INFO",
"message": "Clean operation cancelled.",
"category": "user",
}
],
"data": None,
}
# Only FAILED projects can be cleaned (validated after confirmation)
if not can_clean(current_state):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Clean is only allowed on FAILED projects. "
f"Current state: {current_state.value}. "
f"Use 'project remove' to delete this project."
),
"category": "state_machine",
}
],
"data": None,
}
# Clear cache
cache_clear(project_path)
# Reset state to CREATED, clear diagnostics
update_manifest_field(
project_path,
{
"state": ProjectState.CREATED.value,
"is_stale": False,
"diagnostics": [],
},
)
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"name": manifest.get("name", project_name),
"id": manifest.get("id"),
"state": ProjectState.CREATED.value,
"previous_state": current_state.value,
},
}
# ---------------------------------------------------------------------------
# Project remove
# ---------------------------------------------------------------------------
def _execute_remove(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project remove' subcommand.
Deletes the entire project workspace. Requires user confirmation
unless --yes or --force is provided. Supports --dry-run for preview.
"""
project_name = args.project
yes = getattr(args, "yes", False)
force = getattr(args, "force", False)
dry_run = getattr(args, "dry_run", False)
skip_confirmation = yes or force or dry_run
pw_name = _resolve_project_name(project_name)
project_path = str(get_project_path(pw_name))
# Get paths that would be deleted
paths_to_delete: list[str] = []
try:
subdirs = get_workspace_subdirs(pw_name)
for _name, dir_path in sorted(subdirs.items()):
paths_to_delete.append(str(dir_path))
except Exception:
paths_to_delete.append(project_path)
# Dry-run: preview without deleting
if dry_run:
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"dry_run": True,
"name": pw_name,
"paths": paths_to_delete,
},
}
# Confirmation check
if not skip_confirmation:
try:
prompt = (
f"This will permanently delete project '{pw_name}' "
f"and all its contents ({len(paths_to_delete)} directories). "
f"Continue? [y/N]: "
)
print(prompt, file=sys.stderr, end="", flush=True)
response = sys.stdin.readline().strip().lower()
if response not in ("y", "yes"):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "INFO",
"message": "Remove operation cancelled by user.",
"category": "user",
}
],
"data": None,
}
except (EOFError, KeyboardInterrupt):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "INFO",
"message": "Remove operation cancelled.",
"category": "user",
}
],
"data": None,
}
# Perform deletion
remove_workspace(pw_name)
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"name": pw_name,
"removed": True,
},
}
# ---------------------------------------------------------------------------
# Project migrate
# ---------------------------------------------------------------------------
def _execute_migrate(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'project migrate' subcommand.
Supports --plan (show upgrade path), --apply (perform upgrade),
and --dry-run (preview without mutation). Rejects migrate on locked
projects.
"""
project_name = args.project
plan = getattr(args, "plan", False)
apply_flag = getattr(args, "apply", False)
dry_run = getattr(args, "dry_run", False)
# --dry-run is equivalent to --plan for preview
is_preview = plan or dry_run
is_apply = apply_flag
project_path = _resolve_project_path(project_name)
manifest = load_manifest(project_path)
current_version = manifest.get("workspace_version", "1")
target_version = _WORKSPACE_VERSION
# Read current state
current_state_str = manifest.get("state", "")
try:
current_state = ProjectState(current_state_str)
except ValueError:
current_state = ProjectState.CREATED
locked = is_locked(project_path)
# Reject migrate on locked projects
if is_apply and should_reject_migrate(current_state, locked):
reason_parts = []
if locked:
reason_parts.append("project is currently locked")
if current_state == ProjectState.ANALYZING:
reason_parts.append("project is in ANALYZING state")
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Cannot migrate: {'; '.join(reason_parts)}. "
f"Wait for the operation to complete or release the lock."
),
"category": "state_machine",
}
],
"data": None,
}
# Build migration steps
if current_version == target_version:
migration_steps: list[dict[str, str]] = []
message = "Project is already at the latest workspace version."
else:
migration_steps = [
{
"from_version": current_version,
"to_version": target_version,
"description": f"Upgrade workspace from v{current_version} to v{target_version}",
}
]
message = f"Upgrade from v{current_version} to v{target_version} available."
# Preview mode
if is_preview:
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"current_version": current_version,
"target_version": target_version,
"migration_steps": migration_steps,
"message": message,
"dry_run": True,
},
}
# Apply migration
if is_apply:
if current_version == target_version:
# Already at target — no-op success
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"current_version": current_version,
"target_version": target_version,
"migration_steps": migration_steps,
"applied": False,
"message": "Already at target version; no migration needed.",
},
}
# Perform the upgrade
update_manifest_field(project_path, {"workspace_version": target_version})
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"current_version": target_version,
"target_version": target_version,
"migration_steps": migration_steps,
"applied": True,
"message": f"Migrated from v{current_version} to v{target_version}.",
},
}
# Neither --plan, --dry-run, nor --apply specified — show error
raise InvalidArgsError(
"Migrate requires --plan, --apply, or --dry-run. "
"Use --plan to preview the migration, --apply to perform it."
)