Initial commit: ArchStore package manager for Arch Linux

This commit is contained in:
2026-05-21 02:42:03 +05:30
commit 027847fbac
51 changed files with 6993 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# api package
+1
View File
@@ -0,0 +1 @@
# api.routes package
+33
View File
@@ -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)}")
+109
View File
@@ -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)}")
+53
View File
@@ -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)}")
+1
View File
@@ -0,0 +1 @@
# database package
+141
View File
@@ -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()
+82
View File
@@ -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"}
+5
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# scanner package
+175
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# services package
+164
View File
@@ -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,
}
+228
View File
@@ -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
+246
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# utils package
+69
View File
@@ -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