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 fb3593d8..147224e4 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 @@ -3881,6 +3876,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( @@ -4034,6 +4053,39 @@ def main(): packages=getattr(args, "packages", None), verbose=getattr(args, "verbose", False), ) + elif args.command == "docs": + 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: + 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/cortex/docs_generator.py b/cortex/docs_generator.py new file mode 100644 index 00000000..c114cf86 --- /dev/null +++ b/cortex/docs_generator.py @@ -0,0 +1,344 @@ +import json +import logging +import os +import re +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 + +# Optional dependencies for documentation export +try: + import markdown +except ImportError: + markdown = None + +try: + import pdfkit +except ImportError: + pdfkit = None + +logger = logging.getLogger(__name__) + + +class DocsGenerator: + """Core engine for generating system and software documentation.""" + + 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").resolve() + self.docs_dir.mkdir(parents=True, exist_ok=True) + self.template_base_dir = (Path(__file__).parent / "templates" / "docs").resolve() + + def _sanitize_name(self, software_name: str) -> str: + """Sanitize and validate software name to prevent path traversal.""" + 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() + 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.""" + 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"] == safe_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.""" + safe_name = self._sanitize_name(software_name) + potential_paths = [ + 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 = [] + 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/{safe_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 (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.""" + software_dir = self._get_software_dir(software_name) + 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 is already validated by _get_software_dir + 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.""" + 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: + 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) -> None: + """View a documentation guide in the terminal.""" + software_dir = self._get_software_dir(software_name) + 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 = software_dir / 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.""" + safe_name = self._sanitize_name(software_name) + software_dir = self._get_software_dir(software_name) + + format = format.lower() + if format not in ("md", "html", "pdf"): + raise ValueError(f"Unsupported or invalid export format: {format}") + + if not software_dir.exists(): + self.generate_software_docs(software_name) + + export_path = Path.cwd() / f"{safe_name}_docs.{format}" + + if format == "md": + # Combine all MD files + 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: + 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 + 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)): + 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": + 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 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/cortex/installation_history.py b/cortex/installation_history.py index 38716f85..ee239cc2 100644 --- a/cortex/installation_history.py +++ b/cortex/installation_history.py @@ -322,7 +322,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 +332,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() @@ -359,6 +361,23 @@ def update_installation( conn.commit() logger.info(f"Installation {install_id} updated: {status.value}") + + # 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 + + 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..638447b4 --- /dev/null +++ b/docs/modules/README_DOCS_GENERATOR.md @@ -0,0 +1,81 @@ +# 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, 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. +- **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 +``` + +> [!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}/`. + +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. + +## 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/pyproject.toml b/pyproject.toml index bde49c68..ff3db1eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ dependencies = [ ] [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_cli_docs.py b/tests/test_cli_docs.py new file mode 100644 index 00000000..68872d7f --- /dev/null +++ b/tests/test_cli_docs.py @@ -0,0 +1,36 @@ +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": "/home/user/.cortex/docs/nginx/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 = "/home/user/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_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_generator.py b/tests/test_docs_generator.py new file mode 100644 index 00000000..c4164b90 --- /dev/null +++ b/tests/test_docs_generator.py @@ -0,0 +1,196 @@ +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("cortex.docs_generator.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("cortex.docs_generator.pdfkit", mock_pdfkit), + patch("cortex.docs_generator.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 + + +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() diff --git a/tests/test_docs_security.py b/tests/test_docs_security.py new file mode 100644 index 00000000..6c9231b0 --- /dev/null +++ b/tests/test_docs_security.py @@ -0,0 +1,121 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from cortex.docs_generator import 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 = [ + ("../../outside/secret", "outside_secret"), + ("nginx; rm -rf", "nginx__rm_-rf"), + ("pkg*name", "pkg_name"), + ("pkg$name", "pkg_name"), + (".hidden", "hidden"), + ("trailing.", "trailing"), + ("__leading", "leading"), + ] + + 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._sanitize_name(name) + + +def test_path_traversal_detection(gen, tmp_path): + """Verify that explicit path traversal attempts are detected by parent check.""" + # 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 = [ + 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: + gen._get_software_dir(name) + assert "path escape attempt" in str(excinfo.value) + + +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.""" + malicious_formats = [ + "../../safe_temp/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(gen): + """Verify that legitimate software names are accepted.""" + try: + 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")