From 02253eca531056f9b07961932b56ab3baed24327 Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Sun, 18 Jan 2026 14:42:01 +0530 Subject: [PATCH 1/7] feat: implemet Automatic Documentation Generator --- README.md | 4 + cortex/cli.py | 55 +++- cortex/docs_generator.py | 292 ++++++++++++++++++ cortex/installation_history.py | 13 + .../docs/default/Configuration_Reference.md | 3 + .../docs/default/Installation_Guide.md | 6 + cortex/templates/docs/default/Quick_Start.md | 5 + .../templates/docs/default/Troubleshooting.md | 6 + docs/modules/README_DOCS_GENERATOR.md | 70 +++++ pyproject.toml | 3 + tests/test_cli_docs.py | 34 ++ tests/test_docs_generator.py | 172 +++++++++++ 12 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 cortex/docs_generator.py create mode 100644 cortex/templates/docs/default/Configuration_Reference.md create mode 100644 cortex/templates/docs/default/Installation_Guide.md create mode 100644 cortex/templates/docs/default/Quick_Start.md create mode 100644 cortex/templates/docs/default/Troubleshooting.md create mode 100644 docs/modules/README_DOCS_GENERATOR.md create mode 100644 tests/test_cli_docs.py create mode 100644 tests/test_docs_generator.py diff --git a/README.md b/README.md index 24db1c17..088e5499 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ cortex install "tools for video compression" | **Audit Trail** | Complete history in `~/.cortex/history.db` | | **Hardware-Aware** | Detects GPU, CPU, memory for optimized packages | | **Multi-LLM Support** | Works with Claude, GPT-4, or local Ollama models | +| **Auto-Documentation** | [NEW] Automatically generates system and software guides | --- @@ -192,6 +193,7 @@ cortex role set | `cortex rollback ` | Undo a specific installation | | `cortex --version` | Show version information | | `cortex --help` | Display help message | +| `cortex docs ` | [NEW] Generate and view software documentation | #### Daemon Commands @@ -275,6 +277,7 @@ cortex/ │ ├── packages.py # Package manager wrapper │ ├── hardware_detection.py │ ├── installation_history.py +│ ├── docs_generator.py # [NEW] Documentation system │ └── utils/ # Utility modules ├── daemon/ # C++ background daemon (cortexd) │ ├── src/ # Daemon source code @@ -284,6 +287,7 @@ cortex/ │ └── README.md # Daemon documentation ├── tests/ # Python test suite ├── docs/ # Documentation +│ └── modules/ # [README_DOCS_GENERATOR.md](docs/modules/README_DOCS_GENERATOR.md) ├── examples/ # Example scripts └── scripts/ # Utility scripts ``` diff --git a/cortex/cli.py b/cortex/cli.py index 6638a880..2a99a677 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -21,14 +21,9 @@ ParseResult, format_package_list, ) +from cortex.docs_generator import DocsGenerator from cortex.env_manager import EnvironmentManager, get_env_manager -from cortex.i18n import ( - SUPPORTED_LANGUAGES, - LanguageConfig, - get_language, - set_language, - t, -) +from cortex.i18n import SUPPORTED_LANGUAGES, LanguageConfig, get_language, set_language, t from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType from cortex.llm.interpreter import CommandInterpreter from cortex.network_config import NetworkConfig @@ -3853,6 +3848,30 @@ def main(): help="Enable verbose output", ) + # Automatic Documentation Generator + docs_parser = subparsers.add_parser("docs", help="Automatic documentation generator") + docs_subparsers = docs_parser.add_subparsers(dest="docs_action", help="Documentation actions") + + # docs generate + gen_parser = docs_subparsers.add_parser("generate", help="Generate documentation for software") + gen_parser.add_argument("software", help="Software/Package name") + + # docs export + exp_parser = docs_subparsers.add_parser("export", help="Export documentation to file") + exp_parser.add_argument("software", help="Software/Package name") + exp_parser.add_argument( + "--format", choices=["md", "pdf", "html"], default="md", help="Export format (default: md)" + ) + + # docs view + view_parser = docs_subparsers.add_parser("view", help="View documentation guide") + view_parser.add_argument("software", help="Software/Package name") + view_parser.add_argument( + "guide", + choices=["installation", "config", "quick-start", "troubleshooting"], + help="Guide type to view", + ) + # System Health Score health_parser = subparsers.add_parser("health", help="System health score and recommendations") health_parser.add_argument( @@ -4000,6 +4019,28 @@ def main(): packages=getattr(args, "packages", None), verbose=getattr(args, "verbose", False), ) + elif args.command == "docs": + docs_gen = DocsGenerator() + if args.docs_action == "generate": + cx_print(f"📄 Generating documentation for {args.software}...", "info") + paths = docs_gen.generate_software_docs(args.software) + console.print("\nCreated:") + for name, path in paths.items(): + console.print(f" - {name}") + return 0 + elif args.docs_action == "export": + path = docs_gen.export_docs(args.software, format=args.format) + if "failed" in path.lower(): + cx_print(path, "warning") + else: + cx_print(f"✓ Exported to {path}", "success") + return 0 + elif args.docs_action == "view": + docs_gen.view_guide(args.software, args.guide) + return 0 + else: + docs_parser.print_help() + return 1 elif args.command == "health": from cortex.health_score import run_health_check diff --git a/cortex/docs_generator.py b/cortex/docs_generator.py new file mode 100644 index 00000000..34f29ea1 --- /dev/null +++ b/cortex/docs_generator.py @@ -0,0 +1,292 @@ +import json +import logging +import os +from datetime import datetime +from pathlib import Path +from string import Template +from typing import Any, Optional + +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table + +from cortex.config_manager import ConfigManager +from cortex.hardware_detection import detect_hardware +from cortex.installation_history import InstallationHistory, InstallationStatus + +logger = logging.getLogger(__name__) + + +class DocsGenerator: + """Core engine for generating system and software documentation.""" + + def __init__(self): + self.config_manager = ConfigManager() + self.history = InstallationHistory() + self.console = Console() + self.docs_dir = Path.home() / ".cortex" / "docs" + self.docs_dir.mkdir(parents=True, exist_ok=True) + self.template_base_dir = Path(__file__).parent / "templates" / "docs" + + def _get_system_data(self) -> dict[str, Any]: + """Gather comprehensive system data.""" + hw_info = detect_hardware() + packages = self.config_manager.detect_installed_packages() + + return { + "system": hw_info.to_dict(), + "packages": packages, + "generated_at": datetime.now().isoformat(), + } + + def _get_software_data(self, software_name: str) -> dict[str, Any]: + """Gather documentation data for a specific software/package.""" + # Find package in installed packages + all_packages = self.config_manager.detect_installed_packages() + pkg_info = next((p for p in all_packages if p["name"] == software_name), None) + + # Get installation history for this package + history_records = self.history.get_history(limit=100) + pkg_history = [ + r + for r in history_records + if software_name in r.packages and r.status == InstallationStatus.SUCCESS + ] + + # Latest successful installation + latest_install = pkg_history[0] if pkg_history else None + + # Attempt to find config files (from snapshots if available) + config_files = [] + if latest_install and latest_install.after_snapshot: + for snap in latest_install.after_snapshot: + if snap.package_name == software_name: + config_files = snap.config_files + break + + # If no snapshots, try searching standard locations + if not config_files: + config_files = self._find_config_files(software_name) + + return { + "name": software_name, + "package_info": pkg_info, + "latest_install": latest_install, + "history": pkg_history, + "config_files": config_files, + "generated_at": datetime.now().isoformat(), + } + + def _find_config_files(self, software_name: str) -> list[str]: + """Search for configuration files in standard locations.""" + potential_paths = [ + f"/etc/{software_name}", + f"/etc/{software_name}.conf", + f"/etc/{software_name}/{software_name}.conf", + f"/etc/{software_name}rc", + os.path.expanduser(f"~/.{software_name}rc"), + os.path.expanduser(f"~/.config/{software_name}"), + ] + + found = [] + for path in potential_paths: + if os.path.exists(path): + found.append(path) + + # Also try listing /etc/software_name/ if it's a directory + etc_dir = Path(f"/etc/{software_name}") + if etc_dir.is_dir(): + try: + for item in etc_dir.glob("*"): + if item.is_file() and item.suffix in ( + ".conf", + ".yaml", + ".yml", + ".json", + ".ini", + ): + found.append(str(item)) + except Exception: + pass + + return sorted(set(found)) + + def generate_software_docs(self, software_name: str) -> dict[str, str]: + """Generate multiple MD documents for a software.""" + data = self._get_software_data(software_name) + + docs = { + "Installation_Guide.md": self._render_installation_guide(data), + "Configuration_Reference.md": self._render_config_reference(data), + "Quick_Start.md": self._render_quick_start(data), + "Troubleshooting.md": self._render_troubleshooting(data), + } + + software_dir = self.docs_dir / software_name + software_dir.mkdir(parents=True, exist_ok=True) + + for filename, content in docs.items(): + with open(software_dir / filename, "w") as f: + f.write(content) + + return {filename: str(software_dir / filename) for filename in docs} + + def _get_template(self, software_name: str, guide_name: str) -> Template: + """Load a template for a specific software or the default.""" + software_template = self.template_base_dir / software_name / f"{guide_name}.md" + default_template = self.template_base_dir / "default" / f"{guide_name}.md" + + template_path = software_template if software_template.exists() else default_template + + try: + with open(template_path) as f: + return Template(f.read()) + except Exception as e: + logger.error(f"Failed to load template {guide_name}: {e}") + return Template("# ${name}\n\nDocumentation template missing.") + + def _render_installation_guide(self, data: dict[str, Any]) -> str: + name = data["name"] + pkg = data["package_info"] + install = data["latest_install"] + + history_content = "" + if install: + history_content = ( + f"- **Installed On**: {install.timestamp}\n\n## Installation Commands\n\n```bash\n" + ) + for cmd in install.commands_executed: + history_content += f"{cmd}\n" + history_content += "```\n" + else: + history_content = "\n> [!NOTE]\n> No installation history found in Cortex database.\n" + history_content += ( + "> This software was likely installed manually or before Cortex was configured.\n" + ) + + template = self._get_template(name, "Installation_Guide") + return template.safe_substitute( + name=name, + version=pkg.get("version", "Unknown") if pkg else "Unknown", + source=pkg.get("source", "Unknown") if pkg else "Unknown", + history_content=history_content, + ) + + def _render_config_reference(self, data: dict[str, Any]) -> str: + name = data["name"] + configs = data["config_files"] + + config_content = "" + if configs: + config_content = "Detected configuration files:\n\n" + for cfg in configs: + config_content += f"- `{cfg}`\n" + else: + config_content = "*No specific configuration files detected for this package.*\n" + + template = self._get_template(name, "Configuration_Reference") + return template.safe_substitute(name=name, config_content=config_content) + + def _render_quick_start(self, data: dict[str, Any]) -> str: + name = data["name"] + pkg = data["package_info"] + + quick_start_content = "" + if pkg: + quick_start_content = "## Common Commands\n\n" + if pkg["source"] == "apt": + quick_start_content += ( + f"```bash\nsudo systemctl status {name}\nsudo systemctl start {name}\n```\n" + ) + elif pkg["source"] == "pip": + quick_start_content += f"```bash\npython3 -m {name} --help\n```\n" + + template = self._get_template(name, "Quick_Start") + return template.safe_substitute( + name=name, generated_at=data["generated_at"], quick_start_content=quick_start_content + ) + + def _render_troubleshooting(self, data: dict[str, Any]) -> str: + name = data["name"] + template = self._get_template(name, "Troubleshooting") + return template.safe_substitute(name=name) + + def view_guide(self, software_name: str, guide_type: str): + """View a documentation guide in the terminal.""" + guide_map = { + "installation": "Installation_Guide.md", + "config": "Configuration_Reference.md", + "quick-start": "Quick_Start.md", + "troubleshooting": "Troubleshooting.md", + } + + filename = guide_map.get(guide_type.lower()) + if not filename: + self.console.print(f"[red]Unknown guide type: {guide_type}[/red]") + return + + filepath = self.docs_dir / software_name / filename + if not filepath.exists(): + # Try to generate it + self.generate_software_docs(software_name) + + if filepath.exists(): + with open(filepath) as f: + content = f.read() + self.console.print(Markdown(content)) + else: + self.console.print(f"[red]Documentation not found for {software_name}[/red]") + + def export_docs(self, software_name: str, format: str = "md") -> str: + """Export documentation in various formats.""" + software_dir = self.docs_dir / software_name + if not software_dir.exists(): + self.generate_software_docs(software_name) + + export_path = Path.cwd() / f"{software_name}_docs.{format}" + + if format == "md": + # Combine all MD files + combined = f"# {software_name.capitalize()} Documentation\n\n" + for filename in sorted(os.listdir(software_dir)): + if filename.endswith(".md"): + with open(software_dir / filename) as f: + combined += f.read() + "\n\n---\n\n" + + with open(export_path, "w") as f: + f.write(combined) + + elif format == "html": + # Simple MD to HTML conversion + import markdown + + combined = "" + for filename in sorted(os.listdir(software_dir)): + if filename.endswith(".md"): + with open(software_dir / filename) as f: + combined += f.read() + "\n\n" + + html = markdown.markdown(combined) + wrap = f"{html}" + with open(export_path, "w") as f: + f.write(wrap) + + elif format == "pdf": + # PDF generation requires extra dependencies usually, + # for now let's just create an HTML file and suggest printing to PDF + # or try to use a simple lib if available. + # Since I can't install new sys deps easily, I'll stick to a placeholder/info message + # if a proper PDF lib isn't there. + try: + import pdfkit + + html_path = self.export_docs(software_name, "html") + pdfkit.from_file(html_path, str(export_path)) + os.remove(html_path) + except Exception: + # Fallback or error + export_path = Path.cwd() / f"{software_name}_docs.html" + self.export_docs(software_name, "html") + return f"PDF export failed (missing pdfkit/wkhtmltopdf). Exported to HTML instead: {export_path}" + + return str(export_path) diff --git a/cortex/installation_history.py b/cortex/installation_history.py index 61c559fd..843b89d7 100644 --- a/cortex/installation_history.py +++ b/cortex/installation_history.py @@ -363,6 +363,19 @@ def update_installation( conn.commit() logger.info(f"Installation {install_id} updated: {status.value}") + + # Trigger documentation update for successful installations + if status == InstallationStatus.SUCCESS: + try: + from cortex.docs_generator import DocsGenerator + + docs_gen = DocsGenerator() + for pkg in packages: + docs_gen.generate_software_docs(pkg) + except ImportError: + pass # Might happen during testing or if docs_generator is not yet available + except Exception as e: + logger.warning(f"Failed to auto-update docs for {packages}: {e}") except Exception as e: logger.error(f"Failed to update installation: {e}") raise diff --git a/cortex/templates/docs/default/Configuration_Reference.md b/cortex/templates/docs/default/Configuration_Reference.md new file mode 100644 index 00000000..b332c97f --- /dev/null +++ b/cortex/templates/docs/default/Configuration_Reference.md @@ -0,0 +1,3 @@ +# Configuration Reference: ${name} + +${config_content} diff --git a/cortex/templates/docs/default/Installation_Guide.md b/cortex/templates/docs/default/Installation_Guide.md new file mode 100644 index 00000000..9419f738 --- /dev/null +++ b/cortex/templates/docs/default/Installation_Guide.md @@ -0,0 +1,6 @@ +# Installation Guide: ${name} + +- **Version**: ${version} +- **Source**: ${source} + +${history_content} diff --git a/cortex/templates/docs/default/Quick_Start.md b/cortex/templates/docs/default/Quick_Start.md new file mode 100644 index 00000000..ffbe1162 --- /dev/null +++ b/cortex/templates/docs/default/Quick_Start.md @@ -0,0 +1,5 @@ +# Quick Start: ${name} + +Generated on: ${generated_at} + +${quick_start_content} diff --git a/cortex/templates/docs/default/Troubleshooting.md b/cortex/templates/docs/default/Troubleshooting.md new file mode 100644 index 00000000..317799a6 --- /dev/null +++ b/cortex/templates/docs/default/Troubleshooting.md @@ -0,0 +1,6 @@ +# Troubleshooting: ${name} + +## Common Issues + +1. **Service not starting**: Check logs using `journalctl -u ${name}` +2. **Permission denied**: Ensure you have correct permissions for config files. diff --git a/docs/modules/README_DOCS_GENERATOR.md b/docs/modules/README_DOCS_GENERATOR.md new file mode 100644 index 00000000..cc98b1aa --- /dev/null +++ b/docs/modules/README_DOCS_GENERATOR.md @@ -0,0 +1,70 @@ +# Documentation Generator Module + +The `DocsGenerator` module is responsible for automatically creating, managing, and exporting documentation for software packages and system configurations. + +## Overview + +Cortex provides a comprehensive documentation system that goes beyond simple logs. It uses data gathered during installations, configuration snapshots, and proactive system searches to build useful documentation files. + +## Features + +- **Automated Generation**: Documentation is automatically updated when software is successfully installed or modified via Cortex. +- **Rich Terminal View**: View documentation directly in your terminal with beautiful formatting and syntax highlighting. +- **Multiple Export Formats**: Export unified documentation files in Markdown, HTML, or PDF formats. +- **Customizable Templates**: Customize the layout and content of generated documentation using external templates. +- **Proactive Intelligence**: Attempts to locate configuration files and system paths even if the software wasn't installed via Cortex. + +## Architecture + +The module consists of the following components: + +- **DocsGenerator (`cortex/docs_generator.py`)**: The core engine that orchestrates data gathering, rendering, and file management. +- **Templates (`cortex/templates/docs/`)**: Markdown templates used for different guide types (Installation, Configuration, Quick Start, Troubleshooting). +- **CLI Interface (`cortex/cli.py`)**: The `cortex docs` command group. + +## Usage + +### CLI Commands + +```bash +# Generate documentation for a package +cortex docs generate + +# View a specific guide in the terminal +cortex docs view +# Guide types: installation, config, quick-start, troubleshooting + +# Export documentation to a file +cortex docs export --format +``` + +### Customization + +You can provide custom templates for specific software by creating a directory in `cortex/templates/docs/${software_name}/`. + +The generator looks for the following files: +- `Installation_Guide.md` +- `Configuration_Reference.md` +- `Quick_Start.md` +- `Troubleshooting.md` + +If a software-specific template is missing, it falls back to the templates in `cortex/templates/docs/default/`. + +### Exporting to PDF + +Native PDF export requires the `wkhtmltopdf` system utility to be installed on your machine. + +If it's missing, Cortex will automatically fall back to an HTML export and provide an informational message. + +To enable PDF support, install it via your package manager: +```bash +sudo apt install wkhtmltopdf +``` + +## Data Sources + +The generator gathers data from: +1. **InstallationHistory**: Successful commands and timestamps. +2. **ConfigManager**: System package information and configuration snapshots. +3. **HardwareDetector**: System-wide hardware context. +4. **Proactive Scanner**: Searches `/etc/`, `~/.config/`, and other standard locations for configuration files. diff --git a/pyproject.toml b/pyproject.toml index bde49c68..dec2c7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ dependencies = [ "rich>=13.0.0", # Type hints for older Python versions "typing-extensions>=4.0.0", + # Documentation export + "markdown>=3.0.0", + "pdfkit>=1.0.0", ] [project.optional-dependencies] diff --git a/tests/test_cli_docs.py b/tests/test_cli_docs.py new file mode 100644 index 00000000..6b6363ce --- /dev/null +++ b/tests/test_cli_docs.py @@ -0,0 +1,34 @@ +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from cortex.cli import main + + +@pytest.fixture +def mock_docs_gen(): + with patch("cortex.cli.DocsGenerator") as mock: + yield mock.return_value + + +def test_cli_docs_generate(mock_docs_gen): + mock_docs_gen.generate_software_docs.return_value = {"Test.md": "/tmp/test.md"} + + with patch("sys.argv", ["cortex", "docs", "generate", "nginx"]): + assert main() == 0 + mock_docs_gen.generate_software_docs.assert_called_once_with("nginx") + + +def test_cli_docs_export(mock_docs_gen): + mock_docs_gen.export_docs.return_value = "/tmp/nginx_docs.md" + + with patch("sys.argv", ["cortex", "docs", "export", "nginx", "--format", "pdf"]): + assert main() == 0 + mock_docs_gen.export_docs.assert_called_once_with("nginx", format="pdf") + + +def test_cli_docs_view(mock_docs_gen): + with patch("sys.argv", ["cortex", "docs", "view", "nginx", "quick-start"]): + assert main() == 0 + mock_docs_gen.view_guide.assert_called_once_with("nginx", "quick-start") diff --git a/tests/test_docs_generator.py b/tests/test_docs_generator.py new file mode 100644 index 00000000..acb683c9 --- /dev/null +++ b/tests/test_docs_generator.py @@ -0,0 +1,172 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cortex.docs_generator import DocsGenerator +from cortex.installation_history import InstallationStatus + + +@pytest.fixture +def docs_gen(): + with ( + patch("cortex.docs_generator.ConfigManager"), + patch("cortex.docs_generator.InstallationHistory"), + patch("cortex.docs_generator.detect_hardware"), + ): + yield DocsGenerator() + + +def test_get_system_data(docs_gen): + docs_gen.config_manager.detect_installed_packages.return_value = [{"name": "pkg1"}] + with patch("cortex.docs_generator.detect_hardware") as mock_detect: + mock_detect.return_value.to_dict.return_value = {"cpu": "v1"} + data = docs_gen._get_system_data() + assert data["packages"] == [{"name": "pkg1"}] + assert data["system"] == {"cpu": "v1"} + + +def test_find_config_files(docs_gen, tmp_path): + mock_file = MagicMock(spec=Path) + mock_file.is_file.return_value = True + mock_file.suffix = ".conf" + mock_file.__str__.return_value = "/etc/nginx/nginx.conf" + + with ( + patch("os.path.exists", side_effect=lambda x: x == "/etc/nginx"), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.glob") as mock_glob, + ): + mock_glob.return_value = [mock_file] + found = docs_gen._find_config_files("nginx") + assert "/etc/nginx" in found + assert "/etc/nginx/nginx.conf" in found + + +def test_get_template_exception(docs_gen): + with patch("builtins.open", side_effect=Exception("error")): + template = docs_gen._get_template("any", "any") + assert "template missing" in template.template + + +def test_render_methods_complete(docs_gen): + data = { + "name": "mypkg", + "package_info": {"source": "pip", "version": "2.0"}, + "latest_install": MagicMock( + timestamp="2025-01-01", commands_executed=["pip install mypkg"] + ), + "config_files": [], + "generated_at": "now", + } + + html = docs_gen._render_installation_guide(data) + assert "pip install mypkg" in html + + html = docs_gen._render_quick_start(data) + assert "python3 -m mypkg" in html + + +def test_get_software_data_logic(docs_gen): + docs_gen.config_manager.detect_installed_packages.return_value = [ + {"name": "pkg1", "source": "apt"} + ] + mock_history = MagicMock() + mock_history.packages = ["pkg1"] + mock_history.status = InstallationStatus.SUCCESS + mock_history.after_snapshot = [] + docs_gen.history.get_history.return_value = [mock_history] + + with patch.object(docs_gen, "_find_config_files", return_value=["/etc/cfg"]): + data = docs_gen._get_software_data("pkg1") + assert data["name"] == "pkg1" + assert data["config_files"] == ["/etc/cfg"] + assert data["latest_install"] == mock_history + + +def test_generate_software_docs_real(docs_gen, tmp_path): + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + + # Don't mock _get_software_data, use the logic + docs_gen.config_manager.detect_installed_packages.return_value = [ + {"name": "pkg", "source": "apt"} + ] + docs_gen.history.get_history.return_value = [] + + paths = docs_gen.generate_software_docs("pkg") + assert len(paths) == 4 + for path in paths.values(): + assert Path(path).exists() + + +def test_view_guide_not_found(docs_gen): + docs_gen.console.print = MagicMock() + docs_gen.view_guide("unknown", "quick-start") + assert docs_gen.console.print.called + + +def test_view_guide_found(docs_gen, tmp_path): + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + pkg_dir = docs_gen.docs_dir / "pkg" + pkg_dir.mkdir() + (pkg_dir / "Quick_Start.md").write_text("# H") + + docs_gen.console.print = MagicMock() + docs_gen.view_guide("pkg", "quick-start") + assert docs_gen.console.print.called + + +def test_export_docs_md_logic(docs_gen, tmp_path): + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + pkg_dir = docs_gen.docs_dir / "pkg" + pkg_dir.mkdir() + (pkg_dir / "f.md").write_text("content") + + with patch("os.getcwd", return_value=str(tmp_path)): + path = docs_gen.export_docs("pkg", format="md") + assert Path(path).exists() + assert "content" in Path(path).read_text() + + +def test_export_docs_html_logic(docs_gen, tmp_path): + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + pkg_dir = docs_gen.docs_dir / "pkg" + pkg_dir.mkdir() + (pkg_dir / "f.md").write_text("# H") + + mock_markdown = MagicMock() + mock_markdown.markdown.return_value = "" + + with ( + patch("os.getcwd", return_value=str(tmp_path)), + patch.dict("sys.modules", {"markdown": mock_markdown}), + ): + path = docs_gen.export_docs("pkg", format="html") + assert Path(path).exists() + assert "" in Path(path).read_text() + + +def test_export_pdf_full(docs_gen, tmp_path): + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + (docs_gen.docs_dir / "test-pkg").mkdir() + (docs_gen.docs_dir / "test-pkg" / "test.md").write_text("# Test") + + mock_pdfkit = MagicMock() + mock_markdown = MagicMock() + mock_markdown.markdown.return_value = "" + + with ( + patch("os.getcwd", return_value=str(tmp_path)), + patch.dict("sys.modules", {"pdfkit": mock_pdfkit, "markdown": mock_markdown}), + patch("os.remove"), + ): + + res = docs_gen.export_docs("test-pkg", format="pdf") + assert res.endswith(".pdf") + assert mock_pdfkit.from_file.called From b48535c3d99bdbb0b5d4c66a66daf9c9ad28df2b Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Sun, 18 Jan 2026 16:32:22 +0530 Subject: [PATCH 2/7] feat(docs): automatic documentation generator with security hardening --- cortex/docs_generator.py | 54 ++++++++++++++++++++++++++---------- pyproject.toml | 7 +++-- tests/test_docs_generator.py | 5 ++-- tests/test_docs_security.py | 42 ++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 tests/test_docs_security.py diff --git a/cortex/docs_generator.py b/cortex/docs_generator.py index 34f29ea1..71bbafb0 100644 --- a/cortex/docs_generator.py +++ b/cortex/docs_generator.py @@ -14,6 +14,17 @@ from cortex.hardware_detection import detect_hardware from cortex.installation_history import InstallationHistory, InstallationStatus +# Optional dependencies for documentation export +try: + import markdown +except ImportError: + markdown = None + +try: + import pdfkit +except ImportError: + pdfkit = None + logger = logging.getLogger(__name__) @@ -28,6 +39,16 @@ def __init__(self): self.docs_dir.mkdir(parents=True, exist_ok=True) self.template_base_dir = Path(__file__).parent / "templates" / "docs" + def _validate_software_name(self, software_name: str) -> None: + """Sanitize and validate software name to prevent path traversal.""" + if ( + not software_name + or ".." in software_name + or "/" in software_name + or "\\" in software_name + ): + raise ValueError(f"Invalid characters in software name: {software_name}") + def _get_system_data(self) -> dict[str, Any]: """Gather comprehensive system data.""" hw_info = detect_hardware() @@ -41,6 +62,7 @@ def _get_system_data(self) -> dict[str, Any]: def _get_software_data(self, software_name: str) -> dict[str, Any]: """Gather documentation data for a specific software/package.""" + self._validate_software_name(software_name) # Find package in installed packages all_packages = self.config_manager.detect_installed_packages() pkg_info = next((p for p in all_packages if p["name"] == software_name), None) @@ -106,13 +128,14 @@ def _find_config_files(self, software_name: str) -> list[str]: ".ini", ): found.append(str(item)) - except Exception: - pass + except (PermissionError, OSError) as e: + logger.warning(f"Error scanning {etc_dir} for config files: {e}") return sorted(set(found)) def generate_software_docs(self, software_name: str) -> dict[str, str]: """Generate multiple MD documents for a software.""" + self._validate_software_name(software_name) data = self._get_software_data(software_name) docs = { @@ -213,6 +236,7 @@ def _render_troubleshooting(self, data: dict[str, Any]) -> str: def view_guide(self, software_name: str, guide_type: str): """View a documentation guide in the terminal.""" + self._validate_software_name(software_name) guide_map = { "installation": "Installation_Guide.md", "config": "Configuration_Reference.md", @@ -239,6 +263,7 @@ def view_guide(self, software_name: str, guide_type: str): def export_docs(self, software_name: str, format: str = "md") -> str: """Export documentation in various formats.""" + self._validate_software_name(software_name) software_dir = self.docs_dir / software_name if not software_dir.exists(): self.generate_software_docs(software_name) @@ -258,7 +283,8 @@ def export_docs(self, software_name: str, format: str = "md") -> str: elif format == "html": # Simple MD to HTML conversion - import markdown + if not markdown: + return "Error: 'markdown' package is not installed. Use 'pip install cortex-linux[export]'." combined = "" for filename in sorted(os.listdir(software_dir)): @@ -272,21 +298,19 @@ def export_docs(self, software_name: str, format: str = "md") -> str: f.write(wrap) elif format == "pdf": - # PDF generation requires extra dependencies usually, - # for now let's just create an HTML file and suggest printing to PDF - # or try to use a simple lib if available. - # Since I can't install new sys deps easily, I'll stick to a placeholder/info message - # if a proper PDF lib isn't there. - try: - import pdfkit + if not pdfkit: + html_path = self.export_docs(software_name, "html") + return f"PDF export failed (missing pdfkit). Exported to HTML instead: {html_path}" + try: html_path = self.export_docs(software_name, "html") pdfkit.from_file(html_path, str(export_path)) os.remove(html_path) - except Exception: - # Fallback or error - export_path = Path.cwd() / f"{software_name}_docs.html" - self.export_docs(software_name, "html") - return f"PDF export failed (missing pdfkit/wkhtmltopdf). Exported to HTML instead: {export_path}" + except Exception as e: + # Fallback for runtime error during PDF conversion + html_path = str(Path.cwd() / f"{software_name}_docs.html") + if not Path(html_path).exists(): + self.export_docs(software_name, "html") + return f"PDF export failed ({e}). HTML file was created at: {html_path}" return str(export_path) diff --git a/pyproject.toml b/pyproject.toml index dec2c7fe..ff3db1eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,12 +57,13 @@ dependencies = [ "rich>=13.0.0", # Type hints for older Python versions "typing-extensions>=4.0.0", - # Documentation export - "markdown>=3.0.0", - "pdfkit>=1.0.0", ] [project.optional-dependencies] +export = [ + "markdown>=3.0.0", + "pdfkit>=1.0.0", +] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", diff --git a/tests/test_docs_generator.py b/tests/test_docs_generator.py index acb683c9..e1a46528 100644 --- a/tests/test_docs_generator.py +++ b/tests/test_docs_generator.py @@ -144,7 +144,7 @@ def test_export_docs_html_logic(docs_gen, tmp_path): with ( patch("os.getcwd", return_value=str(tmp_path)), - patch.dict("sys.modules", {"markdown": mock_markdown}), + patch("cortex.docs_generator.markdown", mock_markdown), ): path = docs_gen.export_docs("pkg", format="html") assert Path(path).exists() @@ -163,7 +163,8 @@ def test_export_pdf_full(docs_gen, tmp_path): with ( patch("os.getcwd", return_value=str(tmp_path)), - patch.dict("sys.modules", {"pdfkit": mock_pdfkit, "markdown": mock_markdown}), + patch("cortex.docs_generator.pdfkit", mock_pdfkit), + patch("cortex.docs_generator.markdown", mock_markdown), patch("os.remove"), ): diff --git a/tests/test_docs_security.py b/tests/test_docs_security.py new file mode 100644 index 00000000..31984bb5 --- /dev/null +++ b/tests/test_docs_security.py @@ -0,0 +1,42 @@ +import pytest + +from cortex.docs_generator import DocsGenerator + + +def test_path_traversal_validation(): + """Verify that path traversal attempts are blocked.""" + gen = DocsGenerator() + + malicious_names = [ + "../etc/passwd", + "../../hidden_dir", + "nested/../traversal", + "invalid/name", + "back\\slash", + "", + None, + ] + + for name in malicious_names: + with pytest.raises(ValueError) as excinfo: + gen.generate_software_docs(name) + assert "Invalid characters in software name" in str(excinfo.value) + + with pytest.raises(ValueError): + gen.export_docs(name) + + with pytest.raises(ValueError): + gen.view_guide(name, "installation") + + +def test_safe_software_name(): + """Verify that legitimate software names are accepted.""" + gen = DocsGenerator() + # This shouldn't raise ValueError, but might raise other errors if package doesn't exist + # which is fine for this security test. + try: + gen._validate_software_name("postgresql") + gen._validate_software_name("nginx-common") + gen._validate_software_name("python3.12") + except ValueError: + pytest.fail("Legitimate software name raised ValueError") From fa046e0b0e094219732b405a5e7be25885eba1d4 Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Sun, 18 Jan 2026 16:40:19 +0530 Subject: [PATCH 3/7] test(docs): use safer mock paths in CLI tests --- cortex/cli.py | 49 ++++++++++++++++++++++++++---------------- tests/test_cli_docs.py | 6 ++++-- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/cortex/cli.py b/cortex/cli.py index 0a5609a0..147224e4 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -4054,26 +4054,37 @@ def main(): verbose=getattr(args, "verbose", False), ) elif args.command == "docs": - docs_gen = DocsGenerator() - if args.docs_action == "generate": - cx_print(f"📄 Generating documentation for {args.software}...", "info") - paths = docs_gen.generate_software_docs(args.software) - console.print("\nCreated:") - for name, path in paths.items(): - console.print(f" - {name}") - return 0 - elif args.docs_action == "export": - path = docs_gen.export_docs(args.software, format=args.format) - if "failed" in path.lower(): - cx_print(path, "warning") + try: + docs_gen = DocsGenerator() + if args.docs_action == "generate": + cx_print(f"📄 Generating documentation for {args.software}...", "info") + paths = docs_gen.generate_software_docs(args.software) + console.print("\nCreated:") + for name, path in paths.items(): + console.print(f" - {name}") + return 0 + elif args.docs_action == "export": + path = docs_gen.export_docs(args.software, format=args.format) + if "failed" in path.lower(): + cx_print(path, "warning") + else: + cx_print(f"✓ Exported to {path}", "success") + return 0 + elif args.docs_action == "view": + docs_gen.view_guide(args.software, args.guide) + return 0 else: - cx_print(f"✓ Exported to {path}", "success") - return 0 - elif args.docs_action == "view": - docs_gen.view_guide(args.software, args.guide) - return 0 - else: - docs_parser.print_help() + docs_parser.print_help() + return 1 + except (ValueError, OSError, ImportError) as e: + cx_print(f"Documentation error: {e}", "error") + return 1 + except Exception as e: + cx_print(f"Unexpected documentation error: {e}", "error") + if args.verbose: + import traceback + + traceback.print_exc() return 1 elif args.command == "health": from cortex.health_score import run_health_check diff --git a/tests/test_cli_docs.py b/tests/test_cli_docs.py index 6b6363ce..68872d7f 100644 --- a/tests/test_cli_docs.py +++ b/tests/test_cli_docs.py @@ -13,7 +13,9 @@ def mock_docs_gen(): def test_cli_docs_generate(mock_docs_gen): - mock_docs_gen.generate_software_docs.return_value = {"Test.md": "/tmp/test.md"} + mock_docs_gen.generate_software_docs.return_value = { + "Test.md": "/home/user/.cortex/docs/nginx/Test.md" + } with patch("sys.argv", ["cortex", "docs", "generate", "nginx"]): assert main() == 0 @@ -21,7 +23,7 @@ def test_cli_docs_generate(mock_docs_gen): def test_cli_docs_export(mock_docs_gen): - mock_docs_gen.export_docs.return_value = "/tmp/nginx_docs.md" + mock_docs_gen.export_docs.return_value = "/home/user/nginx_docs.md" with patch("sys.argv", ["cortex", "docs", "export", "nginx", "--format", "pdf"]): assert main() == 0 From 537348a200d7cfbc3cdd6fb39f034869d80121f1 Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Mon, 19 Jan 2026 11:13:51 +0530 Subject: [PATCH 4/7] feat(docs): harden DocsGenerator security and gate auto-doc generation --- cortex/docs_generator.py | 85 ++++++++++++++++--------- cortex/installation_history.py | 24 +++++--- docs/modules/README_DOCS_GENERATOR.md | 13 +++- tests/test_doc_trigger_gating.py | 52 ++++++++++++++++ tests/test_docs_security.py | 89 ++++++++++++++++++++------- 5 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 tests/test_doc_trigger_gating.py diff --git a/cortex/docs_generator.py b/cortex/docs_generator.py index 71bbafb0..b1b1fa0e 100644 --- a/cortex/docs_generator.py +++ b/cortex/docs_generator.py @@ -1,6 +1,7 @@ import json import logging import os +import re from datetime import datetime from pathlib import Path from string import Template @@ -31,24 +32,39 @@ class DocsGenerator: """Core engine for generating system and software documentation.""" - def __init__(self): + def __init__(self) -> None: + """Initialize docs generator, configure paths and helpers.""" self.config_manager = ConfigManager() self.history = InstallationHistory() self.console = Console() - self.docs_dir = Path.home() / ".cortex" / "docs" + self.docs_dir = (Path.home() / ".cortex" / "docs").resolve() self.docs_dir.mkdir(parents=True, exist_ok=True) - self.template_base_dir = Path(__file__).parent / "templates" / "docs" + self.template_base_dir = (Path(__file__).parent / "templates" / "docs").resolve() - def _validate_software_name(self, software_name: str) -> None: + def _sanitize_name(self, software_name: str) -> str: """Sanitize and validate software name to prevent path traversal.""" - if ( - not software_name - or ".." in software_name - or "/" in software_name - or "\\" in software_name - ): + if not software_name: + raise ValueError("Software name cannot be empty") + + # Allow only alphanumeric, dots, underscores, pluses, and hyphens. + # Replace everything else with underscores. + safe = re.sub(r"[^A-Za-z0-9._+-]", "_", software_name).strip("._") + + if not safe: raise ValueError(f"Invalid characters in software name: {software_name}") + return safe + + def _get_software_dir(self, software_name: str) -> Path: + """Get and validate software directory.""" + safe_name = self._sanitize_name(software_name) + software_dir = (self.docs_dir / safe_name).resolve() + + if self.docs_dir not in software_dir.parents: + raise ValueError(f"Invalid software name (path escape attempt): {software_name}") + + return software_dir + def _get_system_data(self) -> dict[str, Any]: """Gather comprehensive system data.""" hw_info = detect_hardware() @@ -62,10 +78,10 @@ def _get_system_data(self) -> dict[str, Any]: def _get_software_data(self, software_name: str) -> dict[str, Any]: """Gather documentation data for a specific software/package.""" - self._validate_software_name(software_name) + safe_name = self._sanitize_name(software_name) # Find package in installed packages all_packages = self.config_manager.detect_installed_packages() - pkg_info = next((p for p in all_packages if p["name"] == software_name), None) + pkg_info = next((p for p in all_packages if p["name"] == safe_name), None) # Get installation history for this package history_records = self.history.get_history(limit=100) @@ -101,13 +117,14 @@ def _get_software_data(self, software_name: str) -> dict[str, Any]: def _find_config_files(self, software_name: str) -> list[str]: """Search for configuration files in standard locations.""" + safe_name = self._sanitize_name(software_name) potential_paths = [ - f"/etc/{software_name}", - f"/etc/{software_name}.conf", - f"/etc/{software_name}/{software_name}.conf", - f"/etc/{software_name}rc", - os.path.expanduser(f"~/.{software_name}rc"), - os.path.expanduser(f"~/.config/{software_name}"), + f"/etc/{safe_name}", + f"/etc/{safe_name}.conf", + f"/etc/{safe_name}/{safe_name}.conf", + f"/etc/{safe_name}rc", + os.path.expanduser(f"~/.{safe_name}rc"), + os.path.expanduser(f"~/.config/{safe_name}"), ] found = [] @@ -116,7 +133,7 @@ def _find_config_files(self, software_name: str) -> list[str]: found.append(path) # Also try listing /etc/software_name/ if it's a directory - etc_dir = Path(f"/etc/{software_name}") + etc_dir = Path(f"/etc/{safe_name}") if etc_dir.is_dir(): try: for item in etc_dir.glob("*"): @@ -135,7 +152,7 @@ def _find_config_files(self, software_name: str) -> list[str]: def generate_software_docs(self, software_name: str) -> dict[str, str]: """Generate multiple MD documents for a software.""" - self._validate_software_name(software_name) + software_dir = self._get_software_dir(software_name) data = self._get_software_data(software_name) docs = { @@ -145,7 +162,7 @@ def generate_software_docs(self, software_name: str) -> dict[str, str]: "Troubleshooting.md": self._render_troubleshooting(data), } - software_dir = self.docs_dir / software_name + # software_dir is already validated by _get_software_dir software_dir.mkdir(parents=True, exist_ok=True) for filename, content in docs.items(): @@ -156,10 +173,16 @@ def generate_software_docs(self, software_name: str) -> dict[str, str]: def _get_template(self, software_name: str, guide_name: str) -> Template: """Load a template for a specific software or the default.""" - software_template = self.template_base_dir / software_name / f"{guide_name}.md" - default_template = self.template_base_dir / "default" / f"{guide_name}.md" - - template_path = software_template if software_template.exists() else default_template + safe_name = self._sanitize_name(software_name) + software_template = (self.template_base_dir / safe_name / f"{guide_name}.md").resolve() + default_template = (self.template_base_dir / "default" / f"{guide_name}.md").resolve() + + if self.template_base_dir not in software_template.parents: + # Fallback to default if someone tries to escape via guide_name or if safe_name is weird + # though safe_name is sanitized. + template_path = default_template + else: + template_path = software_template if software_template.exists() else default_template try: with open(template_path) as f: @@ -234,9 +257,9 @@ def _render_troubleshooting(self, data: dict[str, Any]) -> str: template = self._get_template(name, "Troubleshooting") return template.safe_substitute(name=name) - def view_guide(self, software_name: str, guide_type: str): + def view_guide(self, software_name: str, guide_type: str) -> None: """View a documentation guide in the terminal.""" - self._validate_software_name(software_name) + software_dir = self._get_software_dir(software_name) guide_map = { "installation": "Installation_Guide.md", "config": "Configuration_Reference.md", @@ -249,7 +272,7 @@ def view_guide(self, software_name: str, guide_type: str): self.console.print(f"[red]Unknown guide type: {guide_type}[/red]") return - filepath = self.docs_dir / software_name / filename + filepath = software_dir / filename if not filepath.exists(): # Try to generate it self.generate_software_docs(software_name) @@ -263,7 +286,11 @@ def view_guide(self, software_name: str, guide_type: str): def export_docs(self, software_name: str, format: str = "md") -> str: """Export documentation in various formats.""" - self._validate_software_name(software_name) + software_dir = self._get_software_dir(software_name) + + if format.lower() not in ("md", "html", "pdf"): + raise ValueError(f"Unsupported or invalid export format: {format}") + software_dir = self.docs_dir / software_name if not software_dir.exists(): self.generate_software_docs(software_name) diff --git a/cortex/installation_history.py b/cortex/installation_history.py index fa5a01b6..c408bb9f 100644 --- a/cortex/installation_history.py +++ b/cortex/installation_history.py @@ -105,7 +105,8 @@ def _init_database(self): cursor = conn.cursor() # Create installations table - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS installations ( id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, @@ -119,13 +120,16 @@ def _init_database(self): rollback_available INTEGER, duration_seconds REAL ) - """) + """ + ) # Create index on timestamp - cursor.execute(""" + cursor.execute( + """ CREATE INDEX IF NOT EXISTS idx_timestamp ON installations(timestamp) - """) + """ + ) conn.commit() @@ -322,7 +326,8 @@ def update_installation( # Get packages from record cursor.execute( - "SELECT packages, timestamp FROM installations WHERE id = ?", (install_id,) + "SELECT packages, timestamp, operation_type FROM installations WHERE id = ?", + (install_id,), ) result = cursor.fetchone() @@ -331,6 +336,7 @@ def update_installation( return packages = json.loads(result[0]) + op_type = InstallationType(result[2]) start_time = datetime.datetime.fromisoformat(result[1]) duration = (datetime.datetime.now() - start_time).total_seconds() @@ -360,8 +366,12 @@ def update_installation( logger.info(f"Installation {install_id} updated: {status.value}") - # Trigger documentation update for successful installations - if status == InstallationStatus.SUCCESS: + # Trigger documentation update for successful installations (relevant types only) + if status == InstallationStatus.SUCCESS and op_type in { + InstallationType.INSTALL, + InstallationType.UPGRADE, + InstallationType.CONFIG, + }: try: from cortex.docs_generator import DocsGenerator diff --git a/docs/modules/README_DOCS_GENERATOR.md b/docs/modules/README_DOCS_GENERATOR.md index cc98b1aa..638447b4 100644 --- a/docs/modules/README_DOCS_GENERATOR.md +++ b/docs/modules/README_DOCS_GENERATOR.md @@ -8,7 +8,7 @@ Cortex provides a comprehensive documentation system that goes beyond simple log ## Features -- **Automated Generation**: Documentation is automatically updated when software is successfully installed or modified via Cortex. +- **Automated Generation**: Documentation is automatically updated when software is successfully installed, upgraded, or configured via Cortex. Redundant updates during removals or rollbacks are suppressed. - **Rich Terminal View**: View documentation directly in your terminal with beautiful formatting and syntax highlighting. - **Multiple Export Formats**: Export unified documentation files in Markdown, HTML, or PDF formats. - **Customizable Templates**: Customize the layout and content of generated documentation using external templates. @@ -38,6 +38,9 @@ cortex docs view cortex docs export --format ``` +> [!NOTE] +> Software names are strictly sanitized to a safe subset of characters (`A-Za-z0-9._+-`). Multiple path traversal protections are in place to ensure all file operations stay within the intended directory. + ### Customization You can provide custom templates for specific software by creating a directory in `cortex/templates/docs/${software_name}/`. @@ -68,3 +71,11 @@ The generator gathers data from: 2. **ConfigManager**: System package information and configuration snapshots. 3. **HardwareDetector**: System-wide hardware context. 4. **Proactive Scanner**: Searches `/etc/`, `~/.config/`, and other standard locations for configuration files. + +## Security + +The `DocsGenerator` module is hardened against path traversal and unsafe file access: +- **Input Sanitization**: All software names are sanitized using a strict allowlist. Any illegal characters are replaced with underscores. +- **Path Resolution**: All filesystem operations use resolved, absolute paths to prevent directory escape via symbolic links or relative paths. +- **Parent Validation**: The system explicitly verifies that every software-specific documentation directory is a child of the root documentation storage. +- **Format Filtering**: Documentation exports are restricted to an explicit allowlist of safe formats (`md`, `html`, `pdf`). diff --git a/tests/test_doc_trigger_gating.py b/tests/test_doc_trigger_gating.py new file mode 100644 index 00000000..f96d9981 --- /dev/null +++ b/tests/test_doc_trigger_gating.py @@ -0,0 +1,52 @@ +import datetime +import json +from unittest.mock import MagicMock, patch + +import pytest + +from cortex.installation_history import InstallationHistory, InstallationStatus, InstallationType + + +@pytest.fixture +def history(tmp_path): + db_path = tmp_path / "test_history.db" + return InstallationHistory(db_path=str(db_path)) + + +def test_doc_trigger_gating(history): + # Mock DocsGenerator + with patch("cortex.docs_generator.DocsGenerator") as mock_gen_class: + mock_gen = mock_gen_class.return_value + + # Test cases: (OperationType, Status, ShouldTrigger) + test_cases = [ + (InstallationType.INSTALL, InstallationStatus.SUCCESS, True), + (InstallationType.UPGRADE, InstallationStatus.SUCCESS, True), + (InstallationType.CONFIG, InstallationStatus.SUCCESS, True), + (InstallationType.REMOVE, InstallationStatus.SUCCESS, False), + (InstallationType.PURGE, InstallationStatus.SUCCESS, False), + (InstallationType.ROLLBACK, InstallationStatus.SUCCESS, False), + (InstallationType.INSTALL, InstallationStatus.FAILED, False), + ] + + for op_type, status, should_trigger in test_cases: + mock_gen.generate_software_docs.reset_mock() + + # Record an installation + packages = ["test-pkg"] + install_id = history.record_installation( + operation_type=op_type, + packages=packages, + commands=["test command"], + start_time=datetime.datetime.now(), + ) + + # Update installation + # We need to mock _create_snapshot to avoid running real dpkg/apt commands + with patch.object(history, "_create_snapshot", return_value=[]): + history.update_installation(install_id, status) + + if should_trigger: + mock_gen.generate_software_docs.assert_called_with("test-pkg") + else: + mock_gen.generate_software_docs.assert_not_called() diff --git a/tests/test_docs_security.py b/tests/test_docs_security.py index 31984bb5..e7d83bc1 100644 --- a/tests/test_docs_security.py +++ b/tests/test_docs_security.py @@ -1,42 +1,87 @@ +from unittest.mock import patch + import pytest from cortex.docs_generator import DocsGenerator -def test_path_traversal_validation(): - """Verify that path traversal attempts are blocked.""" +def test_path_sanitization_robust(): + """Verify that software names are sanitized into safe forms.""" gen = DocsGenerator() - malicious_names = [ - "../etc/passwd", - "../../hidden_dir", - "nested/../traversal", - "invalid/name", - "back\\slash", - "", - None, + # Test cases that should be sanitized + test_cases = [ + ("../../etc/passwd", "etc_passwd"), + ("nginx; rm -rf", "nginx__rm_-rf"), + ("pkg*name", "pkg_name"), + ("pkg$name", "pkg_name"), + (".hidden", "hidden"), + ("trailing.", "trailing"), + ("__leading", "leading"), ] - for name in malicious_names: - with pytest.raises(ValueError) as excinfo: - gen.generate_software_docs(name) - assert "Invalid characters in software name" in str(excinfo.value) + for input_name, expected_safe in test_cases: + safe = gen._sanitize_name(input_name) + assert safe == expected_safe + # Test cases that must raise ValueError (empty after sanitization or inherently invalid) + invalid_cases = [ + "", + " ", + "!!!", + "...///...", + "..", + ".", + "/", + "\\", + ] + for name in invalid_cases: with pytest.raises(ValueError): - gen.export_docs(name) + gen._sanitize_name(name) - with pytest.raises(ValueError): - gen.view_guide(name, "installation") + +def test_path_traversal_detection(): + """Verify that explicit path traversal attempts are detected by parent check.""" + gen = DocsGenerator() + + # We patch _sanitize_name to return malicious raw strings to test the parent check in _get_software_dir + with patch.object(gen, "_sanitize_name", side_effect=lambda x: x): + malicious_names = [ + "/tmp/evil", + "../../etc", + "../docs", # resolved path is docs_dir itself, which is not a child of docs_dir.parents + ] + for name in malicious_names: + with pytest.raises(ValueError) as excinfo: + gen._get_software_dir(name) + assert "path escape attempt" in str(excinfo.value) + + +def test_export_format_validation(): + """Verify that illegal export formats are blocked.""" + gen = DocsGenerator() + malicious_formats = [ + "../../tmp/pwned", + "doc.exe", + "php", + "/etc/passwd", + "", + " ", + ] + + for fmt in malicious_formats: + with pytest.raises(ValueError) as excinfo: + gen.export_docs("nginx", format=fmt) + assert "Unsupported or invalid export format" in str(excinfo.value) def test_safe_software_name(): """Verify that legitimate software names are accepted.""" gen = DocsGenerator() - # This shouldn't raise ValueError, but might raise other errors if package doesn't exist - # which is fine for this security test. try: - gen._validate_software_name("postgresql") - gen._validate_software_name("nginx-common") - gen._validate_software_name("python3.12") + gen._sanitize_name("postgresql") + gen._sanitize_name("nginx-common") + gen._sanitize_name("python3.12") + gen._sanitize_name("libssl1.1") except ValueError: pytest.fail("Legitimate software name raised ValueError") From a737278398a6b89ee370d203818c52caeb44ea3e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 05:44:40 +0000 Subject: [PATCH 5/7] [autofix.ci] apply automated fixes --- cortex/installation_history.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cortex/installation_history.py b/cortex/installation_history.py index c408bb9f..ee239cc2 100644 --- a/cortex/installation_history.py +++ b/cortex/installation_history.py @@ -105,8 +105,7 @@ def _init_database(self): cursor = conn.cursor() # Create installations table - cursor.execute( - """ + cursor.execute(""" CREATE TABLE IF NOT EXISTS installations ( id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, @@ -120,16 +119,13 @@ def _init_database(self): rollback_available INTEGER, duration_seconds REAL ) - """ - ) + """) # Create index on timestamp - cursor.execute( - """ + cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_timestamp ON installations(timestamp) - """ - ) + """) conn.commit() From 544ee4143e6d5043dcf44a86ece92b90955b571e Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Mon, 19 Jan 2026 11:47:42 +0530 Subject: [PATCH 6/7] fix(docs): resolve security regression and harden tests --- cortex/docs_generator.py | 6 ++-- tests/test_docs_security.py | 68 +++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/cortex/docs_generator.py b/cortex/docs_generator.py index b1b1fa0e..58199d32 100644 --- a/cortex/docs_generator.py +++ b/cortex/docs_generator.py @@ -286,20 +286,20 @@ def view_guide(self, software_name: str, guide_type: str) -> None: def export_docs(self, software_name: str, format: str = "md") -> str: """Export documentation in various formats.""" + safe_name = self._sanitize_name(software_name) software_dir = self._get_software_dir(software_name) if format.lower() not in ("md", "html", "pdf"): raise ValueError(f"Unsupported or invalid export format: {format}") - software_dir = self.docs_dir / software_name if not software_dir.exists(): self.generate_software_docs(software_name) - export_path = Path.cwd() / f"{software_name}_docs.{format}" + export_path = Path.cwd() / f"{safe_name}_docs.{format}" if format == "md": # Combine all MD files - combined = f"# {software_name.capitalize()} Documentation\n\n" + combined = f"# {safe_name.capitalize()} Documentation\n\n" for filename in sorted(os.listdir(software_dir)): if filename.endswith(".md"): with open(software_dir / filename) as f: diff --git a/tests/test_docs_security.py b/tests/test_docs_security.py index e7d83bc1..6c9231b0 100644 --- a/tests/test_docs_security.py +++ b/tests/test_docs_security.py @@ -1,17 +1,25 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from cortex.docs_generator import DocsGenerator -def test_path_sanitization_robust(): - """Verify that software names are sanitized into safe forms.""" - gen = DocsGenerator() +@pytest.fixture +def gen(tmp_path): + """Fixture to provide a DocsGenerator with a safe, isolated docs_dir.""" + docs_dir = tmp_path / "cortex_docs_test" + docs_dir.mkdir() + generator = DocsGenerator() + generator.docs_dir = docs_dir + return generator + +def test_path_sanitization_robust(gen): + """Verify that software names are sanitized into safe forms.""" # Test cases that should be sanitized test_cases = [ - ("../../etc/passwd", "etc_passwd"), + ("../../outside/secret", "outside_secret"), ("nginx; rm -rf", "nginx__rm_-rf"), ("pkg*name", "pkg_name"), ("pkg$name", "pkg_name"), @@ -40,16 +48,14 @@ def test_path_sanitization_robust(): gen._sanitize_name(name) -def test_path_traversal_detection(): +def test_path_traversal_detection(gen, tmp_path): """Verify that explicit path traversal attempts are detected by parent check.""" - gen = DocsGenerator() - # We patch _sanitize_name to return malicious raw strings to test the parent check in _get_software_dir with patch.object(gen, "_sanitize_name", side_effect=lambda x: x): malicious_names = [ - "/tmp/evil", - "../../etc", - "../docs", # resolved path is docs_dir itself, which is not a child of docs_dir.parents + str(tmp_path / "outside_dir"), + "../../outside_cortex", + "../cortex_docs_test", # resolved path is docs_dir itself, not a child ] for name in malicious_names: with pytest.raises(ValueError) as excinfo: @@ -57,14 +63,43 @@ def test_path_traversal_detection(): assert "path escape attempt" in str(excinfo.value) -def test_export_format_validation(): +def test_export_docs_path_traversal_bypass(gen, tmp_path): + """Verify that export_docs name bypass is fixed using safe temp paths.""" + # A path that would try to escape the test isolated docs_dir + outside_target = str(tmp_path / "not_docs" / "pwned") + malicious_name = f"../../../{outside_target}" + + # We expect sanitized output: "not_docs_pwned_docs.md" in current dir + # NOT escaping to the outside_target + with patch.object(gen, "generate_software_docs") as mock_gen_docs: + with patch("builtins.open", MagicMock()) as mock_open: + with patch("os.listdir", return_value=[]): + # Should NOT raise ValueError if sanitization works + gen.export_docs(malicious_name, format="md") + + # Check for sanitized name in Path.cwd() calls + found_safe_path = False + for call in mock_open.call_args_list: + path_arg = str(call[0][0]) + # Sanitized version of malicious_name should appear + if "not_docs_pwned_docs.md" in path_arg: + found_safe_path = True + # The raw outside_target should NOT appear + if "not_docs/pwned" in path_arg: + pytest.fail( + f"VULNERABILITY: Export path used unsanitized input: {path_arg}" + ) + + assert found_safe_path, "Export path with sanitized name was not opened" + + +def test_export_format_validation(gen): """Verify that illegal export formats are blocked.""" - gen = DocsGenerator() malicious_formats = [ - "../../tmp/pwned", + "../../safe_temp/pwned", "doc.exe", "php", - "/etc/passwd", + "../../../../etc/passwd", "", " ", ] @@ -75,9 +110,8 @@ def test_export_format_validation(): assert "Unsupported or invalid export format" in str(excinfo.value) -def test_safe_software_name(): +def test_safe_software_name(gen): """Verify that legitimate software names are accepted.""" - gen = DocsGenerator() try: gen._sanitize_name("postgresql") gen._sanitize_name("nginx-common") From bdb5bc18e0ee94b0179da2d821e0d30803e81ee3 Mon Sep 17 00:00:00 2001 From: pratyush07-hub Date: Mon, 19 Jan 2026 11:59:39 +0530 Subject: [PATCH 7/7] fix(docs): normalize export format to lowercase after validation --- cortex/docs_generator.py | 3 ++- tests/test_docs_generator.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cortex/docs_generator.py b/cortex/docs_generator.py index 58199d32..c114cf86 100644 --- a/cortex/docs_generator.py +++ b/cortex/docs_generator.py @@ -289,7 +289,8 @@ def export_docs(self, software_name: str, format: str = "md") -> str: safe_name = self._sanitize_name(software_name) software_dir = self._get_software_dir(software_name) - if format.lower() not in ("md", "html", "pdf"): + format = format.lower() + if format not in ("md", "html", "pdf"): raise ValueError(f"Unsupported or invalid export format: {format}") if not software_dir.exists(): diff --git a/tests/test_docs_generator.py b/tests/test_docs_generator.py index e1a46528..c4164b90 100644 --- a/tests/test_docs_generator.py +++ b/tests/test_docs_generator.py @@ -171,3 +171,26 @@ def test_export_pdf_full(docs_gen, tmp_path): res = docs_gen.export_docs("test-pkg", format="pdf") assert res.endswith(".pdf") assert mock_pdfkit.from_file.called + + +def test_export_docs_mixed_case_format(docs_gen, tmp_path): + """Verify that mixed-case formats like 'MD' or 'Html' work correctly.""" + docs_gen.docs_dir = tmp_path / "docs" + docs_gen.docs_dir.mkdir() + pkg_dir = docs_gen.docs_dir / "pkg" + pkg_dir.mkdir() + (pkg_dir / "f.md").write_text("content") + + with patch("os.getcwd", return_value=str(tmp_path)): + # Test 'MD' + path = docs_gen.export_docs("pkg", format="MD") + assert path.endswith(".md") + assert Path(path).exists() + + # Test 'Html' + mock_markdown = MagicMock() + mock_markdown.markdown.return_value = "" + with patch("cortex.docs_generator.markdown", mock_markdown): + path = docs_gen.export_docs("pkg", format="Html") + assert path.endswith(".html") + assert Path(path).exists()