mirror of
https://github.com/0x5t4l1n/AURHub.git
synced 2026-05-26 11:25:50 +00:00
Initial commit: ArchStore package manager for Arch Linux
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# api package
|
||||
@@ -0,0 +1 @@
|
||||
# api.routes package
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Categories API routes for ArchStore.
|
||||
Handles package category browsing.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import package_service
|
||||
|
||||
router = APIRouter(prefix="/api/categories", tags=["categories"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_categories():
|
||||
"""Get all available package categories."""
|
||||
try:
|
||||
categories = await package_service.get_categories()
|
||||
return {"results": categories, "count": len(categories)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get categories: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}")
|
||||
async def get_category_packages(name: str):
|
||||
"""Get packages in a specific category."""
|
||||
try:
|
||||
packages = await package_service.get_category_packages(name)
|
||||
if not packages and name not in [c["name"] for c in await package_service.get_categories()]:
|
||||
raise HTTPException(status_code=404, detail=f"Category '{name}' not found")
|
||||
return {"category": name, "results": packages, "count": len(packages)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get category: {str(e)}")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Package API routes for ArchStore.
|
||||
Handles search, info, install, and remove endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from services import package_service, aur_service
|
||||
from scanner.security import scan_pkgbuild
|
||||
from utils.sanitize import sanitize_package_name, sanitize_search_query
|
||||
|
||||
router = APIRouter(prefix="/api/packages", tags=["packages"])
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_packages(
|
||||
q: str = Query(..., min_length=1, max_length=128, description="Search query"),
|
||||
source: str = Query("all", description="Source filter: all, pacman, aur"),
|
||||
):
|
||||
"""Search packages across pacman and AUR."""
|
||||
try:
|
||||
query = sanitize_search_query(q)
|
||||
results = await package_service.search_packages(query, source)
|
||||
return {"results": results, "count": len(results), "query": query, "source": source}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/installed")
|
||||
async def list_installed():
|
||||
"""List all installed packages."""
|
||||
try:
|
||||
packages = await package_service.list_installed()
|
||||
return {"results": packages, "count": len(packages)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list packages: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}")
|
||||
async def get_package_info(name: str):
|
||||
"""Get detailed info about a specific package."""
|
||||
try:
|
||||
name = sanitize_package_name(name)
|
||||
info = await package_service.get_package_info(name)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail=f"Package '{name}' not found")
|
||||
return info
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get package info: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}/scan")
|
||||
async def scan_package(name: str):
|
||||
"""Scan an AUR package's PKGBUILD for security issues."""
|
||||
try:
|
||||
name = sanitize_package_name(name)
|
||||
pkgbuild = await aur_service.get_pkgbuild(name)
|
||||
if not pkgbuild:
|
||||
return {
|
||||
"package_name": name,
|
||||
"risk_score": 0,
|
||||
"findings": [],
|
||||
"scanned": False,
|
||||
"risk_level": "unknown",
|
||||
"message": "PKGBUILD not found (may be a pacman package)",
|
||||
}
|
||||
result = scan_pkgbuild(name, pkgbuild)
|
||||
return result.to_dict()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{name}/install")
|
||||
async def install_package(name: str):
|
||||
"""Install a package."""
|
||||
try:
|
||||
name = sanitize_package_name(name)
|
||||
result = await package_service.install_package(name)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=500, detail=result["message"])
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{name}/remove")
|
||||
async def remove_package(name: str):
|
||||
"""Remove an installed package."""
|
||||
try:
|
||||
name = sanitize_package_name(name)
|
||||
result = await package_service.remove_package(name)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=500, detail=result["message"])
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Remove failed: {str(e)}")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Updates API routes for ArchStore.
|
||||
Handles update checking and applying updates.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import package_service
|
||||
|
||||
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
async def check_updates():
|
||||
"""Check for available package updates (pacman + AUR)."""
|
||||
try:
|
||||
updates = await package_service.check_updates()
|
||||
return {
|
||||
"results": updates,
|
||||
"count": len(updates),
|
||||
"pacman_count": sum(1 for u in updates if u["source"] == "pacman"),
|
||||
"aur_count": sum(1 for u in updates if u["source"] == "aur"),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Update check failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_updates():
|
||||
"""Apply all available updates. This is a long-running operation."""
|
||||
try:
|
||||
import asyncio
|
||||
from utils.sanitize import sanitize_package_name
|
||||
|
||||
async def _run_command(cmd, timeout=600):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return stdout.decode(), stderr.decode(), proc.returncode
|
||||
|
||||
# Run system update via yay (handles both pacman and AUR)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["yay", "-Syu", "--noconfirm"], timeout=600
|
||||
)
|
||||
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}")
|
||||
@@ -0,0 +1 @@
|
||||
# database package
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
SQLite database manager for ArchStore.
|
||||
Handles connection lifecycle, schema creation, and cache operations.
|
||||
"""
|
||||
|
||||
import aiosqlite
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "archstore.db")
|
||||
CACHE_TTL = 900 # 15 minutes in seconds
|
||||
|
||||
|
||||
class Database:
|
||||
"""Async SQLite database manager."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH):
|
||||
self.db_path = db_path
|
||||
self._db: Optional[aiosqlite.Connection] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Open database connection and create tables."""
|
||||
self._db = await aiosqlite.connect(self.db_path)
|
||||
self._db.row_factory = aiosqlite.Row
|
||||
await self._db.execute("PRAGMA journal_mode=WAL")
|
||||
await self._create_tables()
|
||||
|
||||
async def close(self):
|
||||
"""Close database connection."""
|
||||
if self._db:
|
||||
await self._db.close()
|
||||
self._db = None
|
||||
|
||||
async def _create_tables(self):
|
||||
"""Create required tables if they don't exist."""
|
||||
await self._db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS search_cache (
|
||||
query TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
results TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (query, source)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS package_cache (
|
||||
name TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_search_cache_time
|
||||
ON search_cache(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_package_cache_time
|
||||
ON package_cache(created_at);
|
||||
""")
|
||||
await self._db.commit()
|
||||
|
||||
async def get_cached_search(self, query: str, source: str) -> Optional[list]:
|
||||
"""Get cached search results if still fresh."""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT results, created_at FROM search_cache WHERE query = ? AND source = ?",
|
||||
(query, source)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row and (time.time() - row["created_at"]) < CACHE_TTL:
|
||||
return json.loads(row["results"])
|
||||
return None
|
||||
|
||||
async def set_cached_search(self, query: str, source: str, results: list):
|
||||
"""Cache search results."""
|
||||
await self._db.execute(
|
||||
"""INSERT OR REPLACE INTO search_cache (query, source, results, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(query, source, json.dumps(results), time.time())
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def get_cached_package(self, name: str) -> Optional[dict]:
|
||||
"""Get cached package info if still fresh."""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT data, created_at FROM package_cache WHERE name = ?",
|
||||
(name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row and (time.time() - row["created_at"]) < CACHE_TTL:
|
||||
return json.loads(row["data"])
|
||||
return None
|
||||
|
||||
async def set_cached_package(self, name: str, data: dict):
|
||||
"""Cache package info."""
|
||||
await self._db.execute(
|
||||
"""INSERT OR REPLACE INTO package_cache (name, data, created_at)
|
||||
VALUES (?, ?, ?)""",
|
||||
(name, json.dumps(data), time.time())
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def get_setting(self, key: str, default: str = "") -> str:
|
||||
"""Get a setting value."""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
async def set_setting(self, key: str, value: str):
|
||||
"""Set a setting value."""
|
||||
await self._db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value)
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def clear_cache(self):
|
||||
"""Clear all cached data."""
|
||||
await self._db.execute("DELETE FROM search_cache")
|
||||
await self._db.execute("DELETE FROM package_cache")
|
||||
await self._db.commit()
|
||||
|
||||
async def cleanup_expired(self):
|
||||
"""Remove expired cache entries."""
|
||||
cutoff = time.time() - CACHE_TTL
|
||||
await self._db.execute(
|
||||
"DELETE FROM search_cache WHERE created_at < ?", (cutoff,)
|
||||
)
|
||||
await self._db.execute(
|
||||
"DELETE FROM package_cache WHERE created_at < ?", (cutoff,)
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
|
||||
# Global database instance
|
||||
db = Database()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
ArchStore Backend — Main Application
|
||||
A lightweight package store API for Arch Linux.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from database.db import db
|
||||
from api.routes import packages, updates, categories
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifecycle: startup and shutdown."""
|
||||
# Startup
|
||||
await db.connect()
|
||||
print("✓ Database connected")
|
||||
print("✓ ArchStore backend ready")
|
||||
yield
|
||||
# Shutdown
|
||||
await db.close()
|
||||
print("✗ Database disconnected")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="ArchStore API",
|
||||
description="A lightweight package store API for Arch Linux combining pacman and AUR.",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS — allow frontend dev server
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount route modules
|
||||
app.include_router(packages.router)
|
||||
app.include_router(updates.router)
|
||||
app.include_router(categories.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"name": "ArchStore API",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
"""Detailed health check."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/cache/clear")
|
||||
async def clear_cache():
|
||||
"""Clear all cached data."""
|
||||
await db.clear_cache()
|
||||
return {"status": "ok", "message": "Cache cleared"}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.3
|
||||
httpx==0.28.1
|
||||
aiosqlite==0.21.0
|
||||
pydantic==2.11.3
|
||||
@@ -0,0 +1 @@
|
||||
# scanner package
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
PKGBUILD Security Scanner for ArchStore.
|
||||
Analyzes PKGBUILD files for suspicious or dangerous patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class ScanFinding:
|
||||
"""A single security finding."""
|
||||
severity: str # "critical", "warning", "info"
|
||||
category: str
|
||||
description: str
|
||||
line_number: int = 0
|
||||
line_content: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Complete scan result."""
|
||||
package_name: str
|
||||
risk_score: int = 0 # 0-100
|
||||
findings: list = field(default_factory=list)
|
||||
scanned: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"package_name": self.package_name,
|
||||
"risk_score": self.risk_score,
|
||||
"findings": [
|
||||
{
|
||||
"severity": f.severity,
|
||||
"category": f.category,
|
||||
"description": f.description,
|
||||
"line_number": f.line_number,
|
||||
"line_content": f.line_content,
|
||||
}
|
||||
for f in self.findings
|
||||
],
|
||||
"scanned": self.scanned,
|
||||
"risk_level": _risk_level(self.risk_score),
|
||||
}
|
||||
|
||||
|
||||
def _risk_level(score: int) -> str:
|
||||
if score >= 70:
|
||||
return "critical"
|
||||
elif score >= 40:
|
||||
return "warning"
|
||||
elif score >= 10:
|
||||
return "low"
|
||||
return "safe"
|
||||
|
||||
|
||||
# --- Pattern Definitions ---
|
||||
|
||||
CRITICAL_PATTERNS = [
|
||||
(r'curl\s+.*\|\s*(ba)?sh', "Remote code execution via curl pipe to shell"),
|
||||
(r'wget\s+.*\|\s*(ba)?sh', "Remote code execution via wget pipe to shell"),
|
||||
(r'curl\s+.*\|\s*python', "Remote code execution via curl pipe to python"),
|
||||
(r'eval\s*\$\(', "Dynamic code evaluation with command substitution"),
|
||||
(r'base64\s+(-d|--decode)', "Base64 decoding (possible obfuscation)"),
|
||||
(r'\\x[0-9a-fA-F]{2}', "Hex-encoded characters (possible obfuscation)"),
|
||||
(r'rm\s+-rf\s+(/\s|/\*|/home|/etc|/usr|/var)', "Dangerous recursive deletion of system paths"),
|
||||
(r'chmod\s+[0-7]*777', "Setting world-writable permissions"),
|
||||
(r'chmod\s+\+s', "Setting SUID/SGID bit"),
|
||||
(r'/dev/tcp/', "Direct TCP socket access"),
|
||||
(r'nc\s+-[el]', "Netcat listener (possible backdoor)"),
|
||||
(r'mkfifo.*\|.*sh', "Named pipe shell redirect (possible backdoor)"),
|
||||
]
|
||||
|
||||
WARNING_PATTERNS = [
|
||||
(r'curl\s+', "Network request using curl"),
|
||||
(r'wget\s+', "Network request using wget"),
|
||||
(r'git\s+clone', "Git clone operation"),
|
||||
(r'pip\s+install', "Python pip install (may bypass pacman)"),
|
||||
(r'npm\s+install\s+-g', "Global npm install"),
|
||||
(r'sudo\s+', "Sudo usage in PKGBUILD"),
|
||||
(r'systemctl\s+(enable|start)', "Enabling/starting services"),
|
||||
(r'dd\s+if=', "Direct disk write with dd"),
|
||||
(r'mkfs\.', "Filesystem formatting command"),
|
||||
(r'eval\s+', "Use of eval"),
|
||||
(r'exec\s+', "Use of exec"),
|
||||
]
|
||||
|
||||
INFO_PATTERNS = [
|
||||
(r'source=\(', "Source file declarations"),
|
||||
(r'makedepends=', "Build dependencies declared"),
|
||||
(r'depends=', "Runtime dependencies declared"),
|
||||
(r'sha256sums=|sha512sums=|md5sums=', "Checksum verification present"),
|
||||
(r'check\(\)', "Check function present (good practice)"),
|
||||
]
|
||||
|
||||
|
||||
def scan_pkgbuild(package_name: str, content: str) -> ScanResult:
|
||||
"""
|
||||
Scan a PKGBUILD file for security issues.
|
||||
Returns a ScanResult with findings and risk score.
|
||||
"""
|
||||
result = ScanResult(package_name=package_name, scanned=True)
|
||||
|
||||
if not content or not content.strip():
|
||||
result.findings.append(ScanFinding(
|
||||
severity="warning",
|
||||
category="empty",
|
||||
description="PKGBUILD is empty or could not be retrieved",
|
||||
))
|
||||
result.risk_score = 20
|
||||
return result
|
||||
|
||||
lines = content.split("\n")
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
# Skip comments
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Check critical patterns
|
||||
for pattern, description in CRITICAL_PATTERNS:
|
||||
if re.search(pattern, stripped, re.IGNORECASE):
|
||||
result.findings.append(ScanFinding(
|
||||
severity="critical",
|
||||
category="dangerous_command",
|
||||
description=description,
|
||||
line_number=i,
|
||||
line_content=stripped[:200],
|
||||
))
|
||||
result.risk_score += 25
|
||||
|
||||
# Check warning patterns
|
||||
for pattern, description in WARNING_PATTERNS:
|
||||
if re.search(pattern, stripped, re.IGNORECASE):
|
||||
result.findings.append(ScanFinding(
|
||||
severity="warning",
|
||||
category="suspicious_command",
|
||||
description=description,
|
||||
line_number=i,
|
||||
line_content=stripped[:200],
|
||||
))
|
||||
result.risk_score += 5
|
||||
|
||||
# Check info patterns
|
||||
for pattern, description in INFO_PATTERNS:
|
||||
if re.search(pattern, stripped, re.IGNORECASE):
|
||||
result.findings.append(ScanFinding(
|
||||
severity="info",
|
||||
category="metadata",
|
||||
description=description,
|
||||
line_number=i,
|
||||
line_content=stripped[:200],
|
||||
))
|
||||
|
||||
# Check for missing checksums (security concern)
|
||||
if not re.search(r'(sha256sums|sha512sums|b2sums)=', content):
|
||||
result.findings.append(ScanFinding(
|
||||
severity="warning",
|
||||
category="missing_verification",
|
||||
description="No strong checksum verification (sha256/sha512/b2) found",
|
||||
))
|
||||
result.risk_score += 10
|
||||
|
||||
# Check for missing check() function
|
||||
if "check()" not in content:
|
||||
result.findings.append(ScanFinding(
|
||||
severity="info",
|
||||
category="best_practice",
|
||||
description="No check() function defined (testing not enforced)",
|
||||
))
|
||||
|
||||
# Cap score at 100
|
||||
result.risk_score = min(100, result.risk_score)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
# services package
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
AUR RPC API service for ArchStore.
|
||||
Handles all interactions with the Arch User Repository via the official RPC API
|
||||
and yay for installations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from utils.sanitize import sanitize_package_name, sanitize_search_query
|
||||
|
||||
AUR_RPC_BASE = "https://aur.archlinux.org/rpc/v5"
|
||||
AUR_PACKAGE_URL = "https://aur.archlinux.org/packages"
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
|
||||
"""Run a shell command safely."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
return (
|
||||
stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"),
|
||||
proc.returncode,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return "", "Command timed out", -1
|
||||
except Exception as e:
|
||||
return "", str(e), -1
|
||||
|
||||
|
||||
async def search_packages(query: str) -> list[dict]:
|
||||
"""Search AUR packages using the RPC API."""
|
||||
query = sanitize_search_query(query)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUR_RPC_BASE}/search/{query}",
|
||||
params={"by": "name-desc"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("type") != "search":
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for pkg in data.get("results", []):
|
||||
packages.append(_normalize_aur_package(pkg))
|
||||
|
||||
# Sort by popularity descending
|
||||
packages.sort(key=lambda p: p.get("popularity", 0), reverse=True)
|
||||
return packages[:100] # Limit results
|
||||
|
||||
except (httpx.HTTPError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed info about an AUR package."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUR_RPC_BASE}/info",
|
||||
params={"arg[]": name},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
pkg = _normalize_aur_package(results[0])
|
||||
|
||||
# Check if installed
|
||||
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
|
||||
pkg["installed"] = code == 0
|
||||
|
||||
return pkg
|
||||
|
||||
except (httpx.HTTPError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install an AUR package using yay."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["yay", "-S", "--noconfirm", name], timeout=600
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for AUR package updates."""
|
||||
stdout, _, code = await _run_command(
|
||||
["yay", "-Qua"], timeout=30
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
updates = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
updates.append({
|
||||
"name": parts[0],
|
||||
"current_version": parts[1],
|
||||
"new_version": parts[3],
|
||||
"source": "aur",
|
||||
})
|
||||
return updates
|
||||
|
||||
|
||||
async def get_pkgbuild(name: str) -> Optional[str]:
|
||||
"""Fetch the PKGBUILD content for an AUR package."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
url = f"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={name}"
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_aur_package(pkg: dict) -> dict:
|
||||
"""Convert AUR RPC response to our standard package format."""
|
||||
return {
|
||||
"name": pkg.get("Name", ""),
|
||||
"version": pkg.get("Version", ""),
|
||||
"description": pkg.get("Description", ""),
|
||||
"maintainer": pkg.get("Maintainer", "Orphaned"),
|
||||
"url": pkg.get("URL", ""),
|
||||
"votes": pkg.get("NumVotes", 0),
|
||||
"popularity": pkg.get("Popularity", 0),
|
||||
"out_of_date": pkg.get("OutOfDate") is not None,
|
||||
"first_submitted": pkg.get("FirstSubmitted", 0),
|
||||
"last_modified": pkg.get("LastModified", 0),
|
||||
"source": "aur",
|
||||
"repository": "aur",
|
||||
"aur_url": f"{AUR_PACKAGE_URL}/{pkg.get('Name', '')}",
|
||||
"package_base": pkg.get("PackageBase", ""),
|
||||
"installed": False,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Unified package service for ArchStore.
|
||||
Merges pacman and AUR results, handles deduplication and ranking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from services import pacman_service, aur_service
|
||||
from database.db import db
|
||||
from utils.sanitize import sanitize_search_query, sanitize_package_name, validate_source_filter
|
||||
|
||||
|
||||
# Category definitions — maps friendly names to pacman groups
|
||||
CATEGORIES = {
|
||||
"Development": {
|
||||
"icon": "code",
|
||||
"description": "Programming tools, compilers, and IDEs",
|
||||
"groups": ["base-devel"],
|
||||
"keywords": ["gcc", "git", "python", "nodejs", "rust", "go", "vim", "neovim", "code"],
|
||||
},
|
||||
"System": {
|
||||
"icon": "monitor",
|
||||
"description": "Core system utilities and tools",
|
||||
"groups": ["base", "sys-utils"],
|
||||
"keywords": ["systemd", "kernel", "grub", "filesystem", "coreutils"],
|
||||
},
|
||||
"Network": {
|
||||
"icon": "wifi",
|
||||
"description": "Networking tools, browsers, and servers",
|
||||
"groups": ["network"],
|
||||
"keywords": ["firefox", "chromium", "curl", "wget", "nginx", "ssh"],
|
||||
},
|
||||
"Multimedia": {
|
||||
"icon": "music",
|
||||
"description": "Audio, video, and image tools",
|
||||
"groups": ["multimedia"],
|
||||
"keywords": ["vlc", "mpv", "ffmpeg", "gimp", "audacity", "obs"],
|
||||
},
|
||||
"Games": {
|
||||
"icon": "gamepad-2",
|
||||
"description": "Games and gaming tools",
|
||||
"groups": ["games"],
|
||||
"keywords": ["steam", "lutris", "wine", "gamemode"],
|
||||
},
|
||||
"Desktop": {
|
||||
"icon": "layout-dashboard",
|
||||
"description": "Desktop environments and window managers",
|
||||
"groups": ["gnome", "kde-applications", "xfce4"],
|
||||
"keywords": ["gnome", "kde", "xfce", "i3", "sway", "hyprland"],
|
||||
},
|
||||
"Fonts": {
|
||||
"icon": "type",
|
||||
"description": "Fonts and typography",
|
||||
"groups": ["fonts"],
|
||||
"keywords": ["ttf", "otf", "nerd-fonts", "noto"],
|
||||
},
|
||||
"Security": {
|
||||
"icon": "shield",
|
||||
"description": "Security and privacy tools",
|
||||
"groups": [],
|
||||
"keywords": ["firewall", "gpg", "openssl", "wireguard", "tor"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def search_packages(query: str, source: str = "all") -> list[dict]:
|
||||
"""
|
||||
Search packages from pacman, AUR, or both.
|
||||
Results are deduplicated, merged, and ranked.
|
||||
"""
|
||||
query = sanitize_search_query(query)
|
||||
source = validate_source_filter(source)
|
||||
|
||||
# Check cache first
|
||||
cached = await db.get_cached_search(query, source)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
results = []
|
||||
|
||||
if source in ("all", "pacman"):
|
||||
pacman_task = pacman_service.search_packages(query)
|
||||
else:
|
||||
pacman_task = asyncio.coroutine(lambda: [])()
|
||||
|
||||
if source in ("all", "aur"):
|
||||
aur_task = aur_service.search_packages(query)
|
||||
else:
|
||||
aur_task = asyncio.coroutine(lambda: [])()
|
||||
|
||||
# Run both searches concurrently
|
||||
if source == "all":
|
||||
pacman_results, aur_results = await asyncio.gather(
|
||||
pacman_service.search_packages(query),
|
||||
aur_service.search_packages(query),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(pacman_results, Exception):
|
||||
pacman_results = []
|
||||
if isinstance(aur_results, Exception):
|
||||
aur_results = []
|
||||
results = _merge_results(pacman_results, aur_results)
|
||||
elif source == "pacman":
|
||||
results = await pacman_service.search_packages(query)
|
||||
elif source == "aur":
|
||||
results = await aur_service.search_packages(query)
|
||||
|
||||
# Cache results
|
||||
await db.set_cached_search(query, source, results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed package info, trying pacman first then AUR."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Check cache
|
||||
cached = await db.get_cached_package(name)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try pacman first
|
||||
info = await pacman_service.get_package_info(name)
|
||||
if not info:
|
||||
# Try AUR
|
||||
info = await aur_service.get_package_info(name)
|
||||
if not info:
|
||||
# Check if installed locally
|
||||
info = await pacman_service.get_installed_info(name)
|
||||
|
||||
if info:
|
||||
await db.set_cached_package(name, info)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install a package, auto-detecting source."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Try pacman first
|
||||
info = await pacman_service.get_package_info(name)
|
||||
if info:
|
||||
return await pacman_service.install_package(name)
|
||||
|
||||
# Fall back to AUR via yay
|
||||
return await aur_service.install_package(name)
|
||||
|
||||
|
||||
async def remove_package(name: str) -> dict:
|
||||
"""Remove an installed package."""
|
||||
name = sanitize_package_name(name)
|
||||
return await pacman_service.remove_package(name)
|
||||
|
||||
|
||||
async def list_installed() -> list[dict]:
|
||||
"""List all installed packages."""
|
||||
return await pacman_service.list_installed()
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for all available updates (pacman + AUR)."""
|
||||
pacman_updates, aur_updates = await asyncio.gather(
|
||||
pacman_service.check_updates(),
|
||||
aur_service.check_updates(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(pacman_updates, Exception):
|
||||
pacman_updates = []
|
||||
if isinstance(aur_updates, Exception):
|
||||
aur_updates = []
|
||||
|
||||
return pacman_updates + aur_updates
|
||||
|
||||
|
||||
async def get_categories() -> list[dict]:
|
||||
"""Get list of package categories."""
|
||||
return [
|
||||
{"name": name, **data}
|
||||
for name, data in CATEGORIES.items()
|
||||
]
|
||||
|
||||
|
||||
async def get_category_packages(category: str) -> list[dict]:
|
||||
"""Get packages for a specific category using search."""
|
||||
cat = CATEGORIES.get(category)
|
||||
if not cat:
|
||||
return []
|
||||
|
||||
# Search using category keywords
|
||||
all_results = []
|
||||
for keyword in cat.get("keywords", [])[:5]:
|
||||
try:
|
||||
results = await search_packages(keyword, "all")
|
||||
all_results.extend(results)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Deduplicate by name
|
||||
seen = set()
|
||||
unique = []
|
||||
for pkg in all_results:
|
||||
if pkg["name"] not in seen:
|
||||
seen.add(pkg["name"])
|
||||
unique.append(pkg)
|
||||
|
||||
return unique[:50]
|
||||
|
||||
|
||||
def _merge_results(pacman: list[dict], aur: list[dict]) -> list[dict]:
|
||||
"""Merge and deduplicate pacman + AUR results."""
|
||||
seen = {}
|
||||
|
||||
# Pacman results take priority
|
||||
for pkg in pacman:
|
||||
seen[pkg["name"]] = pkg
|
||||
|
||||
# Add AUR results if not already from pacman
|
||||
for pkg in aur:
|
||||
if pkg["name"] not in seen:
|
||||
seen[pkg["name"]] = pkg
|
||||
|
||||
# Sort: installed first, then by name
|
||||
results = list(seen.values())
|
||||
results.sort(key=lambda p: (not p.get("installed", False), p.get("name", "")))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Pacman service for ArchStore.
|
||||
Handles all interactions with the pacman package manager via subprocess.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Optional
|
||||
from utils.sanitize import sanitize_package_name
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
|
||||
"""
|
||||
Run a shell command safely using argument list (no shell=True).
|
||||
Returns (stdout, stderr, returncode).
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
return (
|
||||
stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"),
|
||||
proc.returncode,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return "", "Command timed out", -1
|
||||
except Exception as e:
|
||||
return "", str(e), -1
|
||||
|
||||
|
||||
def _parse_search_results(output: str) -> list[dict]:
|
||||
"""Parse pacman -Ss output into structured results."""
|
||||
packages = []
|
||||
lines = output.strip().split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
# Match: repo/name version [installed] or repo/name version
|
||||
match = re.match(
|
||||
r'^(\S+)/(\S+)\s+(\S+)(?:\s+\[installed(?::?\s*(\S+))?\])?\s*$',
|
||||
line
|
||||
)
|
||||
if match:
|
||||
repo = match.group(1)
|
||||
name = match.group(2)
|
||||
version = match.group(3)
|
||||
installed = match.group(4) is not None or "[installed" in line
|
||||
description = ""
|
||||
if i + 1 < len(lines) and lines[i + 1].startswith(" "):
|
||||
description = lines[i + 1].strip()
|
||||
i += 1
|
||||
packages.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"repository": repo,
|
||||
"source": "pacman",
|
||||
"installed": installed,
|
||||
})
|
||||
i += 1
|
||||
return packages
|
||||
|
||||
|
||||
def _parse_package_info(output: str) -> dict:
|
||||
"""Parse pacman -Si or -Qi output into a dict."""
|
||||
info = {}
|
||||
current_key = None
|
||||
current_value = []
|
||||
|
||||
for line in output.split("\n"):
|
||||
if ":" in line and not line.startswith(" "):
|
||||
if current_key:
|
||||
info[current_key] = " ".join(current_value).strip()
|
||||
parts = line.split(":", 1)
|
||||
current_key = parts[0].strip().lower().replace(" ", "_")
|
||||
current_value = [parts[1].strip()] if len(parts) > 1 else []
|
||||
elif line.startswith(" ") and current_key:
|
||||
current_value.append(line.strip())
|
||||
|
||||
if current_key:
|
||||
info[current_key] = " ".join(current_value).strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def search_packages(query: str) -> list[dict]:
|
||||
"""Search pacman repositories."""
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Ss", query], timeout=15
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
return _parse_search_results(stdout)
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed info about a package from sync db."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Try sync database first
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Si", name], timeout=10
|
||||
)
|
||||
if code == 0:
|
||||
info = _parse_package_info(stdout)
|
||||
info["source"] = "pacman"
|
||||
info["installed"] = await is_installed(name)
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_installed_info(name: str) -> Optional[dict]:
|
||||
"""Get info about an installed package."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Qi", name], timeout=10
|
||||
)
|
||||
if code == 0:
|
||||
info = _parse_package_info(stdout)
|
||||
info["source"] = "pacman"
|
||||
info["installed"] = True
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
async def is_installed(name: str) -> bool:
|
||||
"""Check if a package is installed."""
|
||||
name = sanitize_package_name(name)
|
||||
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
|
||||
return code == 0
|
||||
|
||||
|
||||
async def list_installed() -> list[dict]:
|
||||
"""List all explicitly installed packages."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Qe"], timeout=15
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
packages.append({
|
||||
"name": parts[0],
|
||||
"version": parts[1],
|
||||
"source": "pacman",
|
||||
"installed": True,
|
||||
})
|
||||
return packages
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for available updates using checkupdates."""
|
||||
stdout, _, code = await _run_command(
|
||||
["checkupdates"], timeout=60
|
||||
)
|
||||
# checkupdates returns 2 if no updates, 0 if updates available
|
||||
if code not in (0,):
|
||||
return []
|
||||
|
||||
updates = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
updates.append({
|
||||
"name": parts[0],
|
||||
"current_version": parts[1],
|
||||
"new_version": parts[3],
|
||||
"source": "pacman",
|
||||
})
|
||||
return updates
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install a package using pacman (requires pkexec)."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pkexec", "pacman", "-S", "--noconfirm", name], timeout=300
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def remove_package(name: str) -> dict:
|
||||
"""Remove a package using pacman (requires pkexec)."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pkexec", "pacman", "-R", "--noconfirm", name], timeout=120
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def get_package_groups() -> list[str]:
|
||||
"""Get list of all package groups."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Sg"], timeout=10
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
groups = set()
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
groups.add(line.strip().split()[0])
|
||||
return sorted(groups)
|
||||
|
||||
|
||||
async def get_group_packages(group: str) -> list[dict]:
|
||||
"""Get packages in a specific group."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Sg", group], timeout=10
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2:
|
||||
is_inst = await is_installed(parts[1])
|
||||
packages.append({
|
||||
"name": parts[1],
|
||||
"source": "pacman",
|
||||
"installed": is_inst,
|
||||
"group": parts[0],
|
||||
})
|
||||
return packages
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Sanitization utilities for ArchStore.
|
||||
Prevents command injection and validates all user-supplied input
|
||||
before it reaches any shell command.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Strict whitelist: only allow valid package name characters
|
||||
# Arch package names: lowercase letters, digits, @, ., _, +, -
|
||||
PACKAGE_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9@._+\-]+$')
|
||||
|
||||
# Maximum lengths
|
||||
MAX_PACKAGE_NAME_LENGTH = 256
|
||||
MAX_SEARCH_QUERY_LENGTH = 128
|
||||
|
||||
|
||||
def sanitize_package_name(name: str) -> str:
|
||||
"""
|
||||
Validate and sanitize a package name.
|
||||
Raises ValueError if the name contains invalid characters.
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Package name cannot be empty")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if len(name) > MAX_PACKAGE_NAME_LENGTH:
|
||||
raise ValueError(f"Package name too long (max {MAX_PACKAGE_NAME_LENGTH} chars)")
|
||||
|
||||
if not PACKAGE_NAME_PATTERN.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid package name '{name}'. "
|
||||
"Only letters, digits, @, ., _, +, - are allowed."
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def sanitize_search_query(query: str) -> str:
|
||||
"""
|
||||
Validate and sanitize a search query.
|
||||
More permissive than package names but still safe.
|
||||
"""
|
||||
if not query or not isinstance(query, str):
|
||||
raise ValueError("Search query cannot be empty")
|
||||
|
||||
query = query.strip()
|
||||
|
||||
if len(query) > MAX_SEARCH_QUERY_LENGTH:
|
||||
raise ValueError(f"Search query too long (max {MAX_SEARCH_QUERY_LENGTH} chars)")
|
||||
|
||||
# Remove any shell metacharacters
|
||||
dangerous_chars = set(';&|`$(){}[]!#~\\<>"\'\n\r\t')
|
||||
sanitized = ''.join(c for c in query if c not in dangerous_chars)
|
||||
|
||||
if not sanitized:
|
||||
raise ValueError("Search query contains only invalid characters")
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def validate_source_filter(source: str) -> str:
|
||||
"""Validate the source filter parameter."""
|
||||
allowed = {"all", "pacman", "aur"}
|
||||
source = source.strip().lower()
|
||||
if source not in allowed:
|
||||
raise ValueError(f"Invalid source filter '{source}'. Allowed: {', '.join(allowed)}")
|
||||
return source
|
||||
Reference in New Issue
Block a user