"""File and directory mutation services shared by REST routes and the AI tool layer. Single source of truth for the write-side operations (create, edit, append, rename, move, delete, restore, find/replace). Every function performs the anti path-traversal check, the read-only guard and an automatic backup before any destructive change, then returns a JSON-friendly result dict. Index refresh, SSE broadcasts, webhooks and audit logging stay in the route / agent layers: these services are synchronous and side-effect free apart from the filesystem mutation (plus the backup). """ from __future__ import annotations import logging import os import shutil from collections.abc import Callable from pathlib import Path from typing import Any from backend.services.backups import create_backup, get_backup_dir from backend.services.errors import ServiceError from backend.services.paths import resolve_safe_path from backend.services.vaults import get_vault_root logger = logging.getLogger("obsigate.services.mutations") # Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic). _EXCALIDRAW_SKELETON = ( '{"type":"excalidraw","version":2,"elements":[],' '"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}' ) def _rel(root: Path, path: Path) -> str: """Return *path* relative to *root* as a POSIX-style string.""" return str(path.relative_to(root)).replace("\\", "/") def _ensure_writable(root: Path) -> None: """Raise ``read_only`` (403) when the vault root is not writable.""" if not os.access(root, os.W_OK): raise ServiceError("Vault is read-only", code="read_only", status=403) def _validate_extension(file_path: Path, *, allow_images: bool = False) -> None: """Reject unsupported file extensions (400).""" from backend.indexer import SUPPORTED_EXTENSIONS ext = file_path.suffix.lower() allowed = SUPPORTED_EXTENSIONS if allow_images: from backend.attachment_indexer import IMAGE_EXTENSIONS allowed = allowed | IMAGE_EXTENSIONS if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"): raise ServiceError( f"Unsupported file extension: {ext}", code="unsupported_extension", status=400, details={"extension": ext}, ) def _validate_new_name(name: str) -> str: """Validate a rename target (a plain name, not a path).""" candidate = (name or "").strip() if not candidate or candidate in (".", "..") or "/" in candidate or "\\" in candidate: raise ServiceError( f"Invalid name: {name!r}", code="invalid_arguments", status=400, details={"new_name": name}, ) return candidate # ── D1. Creation ─────────────────────────────────────────────────────────── def create_file( vault_name: str, path: str, content: str = "", *, overwrite: bool = False, ) -> dict[str, Any]: """Create a text file in a vault. Args: vault_name: Name of the vault. path: Vault-relative path of the new file. content: Initial content (an Excalidraw skeleton is injected for empty ``.excalidraw`` files). overwrite: When True, replace an existing file (with a backup) instead of raising ``already_exists``. Returns: ``{"success", "vault", "path", "size"}``. Raises: ServiceError: ``not_found`` (404) unknown vault, ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) _validate_extension(file_path) if file_path.exists(): if not overwrite: raise ServiceError( f"File already exists: {path}", code="already_exists", status=409, details={"vault": vault_name, "path": path}, ) create_backup(file_path, vault_name, _rel(root, file_path)) try: file_path.parent.mkdir(parents=True, exist_ok=True) if file_path.suffix.lower() == ".excalidraw" and not content.strip(): content = _EXCALIDRAW_SKELETON file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e rel_path = _rel(root, file_path) logger.info(f"File created: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]: """Create a directory (and its parents) in a vault. Args: vault_name: Name of the vault. path: Vault-relative path of the new directory. exist_ok: When True, an existing directory is a success (idempotent) instead of raising ``already_exists``. Used by the AI tool layer so a "create folder then create file" plan does not fail when the folder is already there (``create_file`` creates parents anyway). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403) or ``already_exists`` (409) when *exist_ok* is False. """ root = get_vault_root(vault_name) _ensure_writable(root) dir_path = resolve_safe_path(root, path) if dir_path.exists(): if exist_ok and dir_path.is_dir(): return { "success": True, "vault": vault_name, "path": _rel(root, dir_path), "existed": True, } raise ServiceError( f"Directory already exists: {path}", code="already_exists", status=409, details={"vault": vault_name, "path": path}, ) try: dir_path.mkdir(parents=True, exist_ok=False) except PermissionError as e: raise ServiceError("Permission denied: cannot create directory", code="permission_denied", status=403) from e rel_path = _rel(root, dir_path) logger.info(f"Directory created: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path} # ── D2. Edition / rename / move ──────────────────────────────────────────── def edit_file( vault_name: str, path: str, content: str, *, backup: bool = True, ) -> dict[str, Any]: """Overwrite an existing file's content (with a backup by default). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e logger.info(f"File saved: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} def append_to_file( vault_name: str, path: str, content: str, *, backup: bool = True, ) -> dict[str, Any]: """Append text to an existing file (a newline is inserted if needed). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: existing = file_path.read_text(encoding="utf-8", errors="replace") separator = "" if (not existing or existing.endswith("\n")) else "\n" new_content = existing + separator + content file_path.write_text(new_content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot append to file", code="permission_denied", status=403) from e logger.info(f"File appended: {vault_name}/{rel_path} (+{len(content)} chars)") return { "success": True, "vault": vault_name, "path": rel_path, "appended": len(content), "size": len(new_content), } def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]: """Rename a file in place (same parent directory). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) new_path = old_path.parent / _validate_new_name(new_name) new_path = resolve_safe_path(root, _rel(root, new_path)) _validate_extension(new_path) if new_path.exists(): raise ServiceError( f"Destination already exists: {new_name}", code="already_exists", status=409, details={"vault": vault_name, "new_name": new_name}, ) old_rel = _rel(root, old_path) try: old_path.rename(new_path) except PermissionError as e: raise ServiceError("Permission denied: cannot rename file", code="permission_denied", status=403) from e new_rel = _rel(root, new_path) logger.info(f"File renamed: {vault_name}/{old_rel} -> {new_rel}") return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any]: """Rename a directory in place (same parent directory). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_dir(): raise ServiceError( f"Directory not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) new_path = old_path.parent / _validate_new_name(new_name) new_path = resolve_safe_path(root, _rel(root, new_path)) if new_path.exists(): raise ServiceError( f"Destination already exists: {new_name}", code="already_exists", status=409, details={"vault": vault_name, "new_name": new_name}, ) old_rel = _rel(root, old_path) try: old_path.rename(new_path) except PermissionError as e: raise ServiceError("Permission denied: cannot rename directory", code="permission_denied", status=403) from e new_rel = _rel(root, new_path) logger.info(f"Directory renamed: {vault_name}/{old_rel} -> {new_rel}") return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]: """Move a file or directory to another directory within the same vault. The item keeps its name; only its parent directory changes. Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) source = resolve_safe_path(root, source_path) if not source.exists(): raise ServiceError( f"Source not found: {source_path}", code="not_found", status=404, details={"vault": vault_name, "path": source_path}, ) is_directory = source.is_dir() item_name = source.name dest_clean = (destination_dir or "").strip("/") if dest_clean: dest_parent = resolve_safe_path(root, dest_clean) if not dest_parent.exists() or not dest_parent.is_dir(): raise ServiceError( f"Destination directory not found: {destination_dir}", code="not_found", status=404, details={"vault": vault_name, "path": destination_dir}, ) else: dest_parent = root destination = resolve_safe_path(root, _rel(root, dest_parent / item_name)) if source.resolve() == destination.resolve(): rel = _rel(root, source) return { "success": True, "vault": vault_name, "old_path": rel, "new_path": rel, "item_type": "directory" if is_directory else "file", } if destination.exists(): raise ServiceError( f"A file or directory already exists at the destination: {destination.name}", code="already_exists", status=409, details={"vault": vault_name, "new_path": _rel(root, destination)}, ) if not is_directory: _validate_extension(destination) old_rel = _rel(root, source) try: source.rename(destination) except PermissionError as e: raise ServiceError("Permission denied: cannot move item", code="permission_denied", status=403) from e new_rel = _rel(root, destination) item_type = "directory" if is_directory else "file" logger.info(f"Item moved: {vault_name}/{old_rel} -> {new_rel}") return { "success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel, "item_type": item_type, } # ── D3. Find & replace ───────────────────────────────────────────────────── def replace_in_files( find: str, replacement: str, *, vault: str = "all", case_sensitive: bool = False, whole_word: bool = False, regex: bool = False, include_paths: str | None = None, exclude_paths: str | None = None, replace_all: bool = False, dry_run: bool = True, is_vault_allowed: Callable[[str], bool] | None = None, ) -> dict[str, Any]: """Find and replace text across vault files (dry-run by default). A backup is created before every file is rewritten. When ``is_vault_allowed`` is provided, files from vaults it rejects are skipped (used by the tool layer to enforce per-vault permissions and the destructive-tools toggle). Returns: ``{"matches", "total_matches"}`` in dry-run mode (with ``"dry_run": True``) or ``{"replaced", "total_replacements"}`` when applied. """ import re as re_mod from backend.services.regex_safety import MAX_REGEX_MATCHES, validate_regex from backend.services.search import advanced_search_vaults if not find: raise ServiceError("Query is required", code="invalid_arguments", status=400) # BUG-025: validate the pattern before it is compiled / applied in bulk. if regex: try: validate_regex(find) except ValueError as e: raise ServiceError(str(e), code="invalid_arguments", status=400) from e try: search_results = advanced_search_vaults( find, vault=vault, case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, include_paths=include_paths, exclude_paths=exclude_paths, limit=500, sort="relevance", ) except ValueError as e: raise ServiceError(str(e), code="invalid_arguments", status=400) from e if not search_results["results"]: return {"matches": [], "total_matches": 0, "dry_run": dry_run} flags = 0 if case_sensitive else re_mod.IGNORECASE if regex: pattern = re_mod.compile(find, flags) elif whole_word: pattern = re_mod.compile(rf"\b{re_mod.escape(find)}\b", flags) else: pattern = re_mod.compile(re_mod.escape(find), flags) matches: list[dict[str, Any]] = [] total = 0 for result in search_results["results"]: result_vault = result["vault"] if is_vault_allowed is not None and not is_vault_allowed(result_vault): continue try: root = get_vault_root(result_vault) file_path = resolve_safe_path(root, result["path"]) except ServiceError: continue if not file_path.exists() or not file_path.is_file(): continue try: original = file_path.read_text(encoding="utf-8", errors="replace") except OSError: continue occurrences = list(pattern.finditer(original))[:MAX_REGEX_MATCHES] if not occurrences: continue if dry_run: previews = [] for m in occurrences[:3]: start = max(0, m.start() - 40) end = min(len(original), m.end() + 40) previews.append(f"...{original[start:end]}...") matches.append({ "vault": result_vault, "path": result["path"], "title": result.get("title", result["path"]), "match_count": len(occurrences), "preview": previews, }) total += len(occurrences) continue new_content, count = pattern.subn(replacement, original) if count == 0: continue create_backup(file_path, result_vault, result["path"]) try: file_path.write_text(new_content, encoding="utf-8") except PermissionError as e: raise ServiceError( f"Permission denied writing {result['path']}", code="permission_denied", status=403, ) from e matches.append({ "vault": result_vault, "path": result["path"], "title": result.get("title", result["path"]), "replacements": count, "size": len(new_content), }) total += count if dry_run: return {"matches": matches, "total_matches": total, "dry_run": True} return {"replaced": matches, "total_replacements": total, "dry_run": False} # ── D4. Deletion / restore ───────────────────────────────────────────────── def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str, Any]: """Delete a file (with a backup by default). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: file_path.unlink() except PermissionError as e: raise ServiceError("Permission denied: cannot delete file", code="permission_denied", status=403) from e logger.info(f"File deleted: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path} def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> dict[str, Any]: """Delete a directory (recursively by default). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``not_empty`` (409) when non-recursive and not empty, or ``invalid_arguments`` (400) when targeting the vault root. """ root = get_vault_root(vault_name) _ensure_writable(root) dir_path = resolve_safe_path(root, path) if not dir_path.exists() or not dir_path.is_dir(): raise ServiceError( f"Directory not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) if dir_path.resolve() == root.resolve(): raise ServiceError( "Refusing to delete the vault root", code="invalid_arguments", status=400, details={"vault": vault_name}, ) file_count = sum(1 for p in dir_path.rglob("*") if p.is_file()) try: if recursive: shutil.rmtree(dir_path) else: if any(dir_path.iterdir()): raise ServiceError( f"Directory not empty: {path}", code="not_empty", status=409, details={"vault": vault_name, "path": path}, ) dir_path.rmdir() except PermissionError as e: raise ServiceError("Permission denied: cannot delete directory", code="permission_denied", status=403) from e rel_path = _rel(root, dir_path) logger.info(f"Directory deleted: {vault_name}/{rel_path} ({file_count} files)") return {"success": True, "vault": vault_name, "path": rel_path, "deleted_count": file_count} def restore_backup( vault_name: str, path: str, version: int, *, backup: bool = True, ) -> dict[str, Any]: """Restore a file from a backup version. The current file is backed up first (when ``backup`` is True) so the operation is reversible. Raises: ServiceError: ``not_found`` (404) when the file or backup is missing, or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) backup_dir = get_backup_dir(vault_name, path) backup_path = backup_dir / f"{Path(path).name}.{version}.bak" if not backup_path.exists(): raise ServiceError( f"Backup version {version} not found for {path}", code="not_found", status=404, details={"vault": vault_name, "path": path, "version": version}, ) # Read the target version *before* backing up the current file: both use a # second-resolution timestamp, so a same-second backup could otherwise # overwrite the version we are about to restore. try: content = backup_path.read_text(encoding="utf-8") except OSError as e: raise ServiceError( f"Failed to read backup {version}: {e}", code="read_error", status=500, details={"vault": vault_name, "path": path, "version": version}, ) from e current_backed_up: int | None = None if backup and file_path.exists() and file_path.is_file(): import time as _time create_backup(file_path, vault_name, path) current_backed_up = int(_time.time()) try: file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot restore file", code="permission_denied", status=403) from e logger.info(f"File restored from backup: {vault_name}/{path} <- version {version}") return { "success": True, "vault": vault_name, "path": path, "restored_from": version, "current_backed_up": current_backed_up, } # ── D3. Batch upload & raw file save ─────────────────────────────────────── def save_raw_file( vault_name: str, path: str, content: bytes, *, overwrite: bool = True, ) -> dict[str, Any]: """Save a binary or text file to a vault (e.g. from upload / drag-and-drop). Creates parent directories automatically and safely validates the path. Supports supported text extensions, images and Excalidraw files. Args: vault_name: Name of the vault. path: Vault-relative path. content: Raw bytes to write. overwrite: When True, replace existing files (with backup). Returns: Dict with ``success``, ``vault``, ``path``, and ``size``. """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) _validate_extension(file_path, allow_images=True) rel_path = _rel(root, file_path) if file_path.exists(): if not overwrite: raise ServiceError( f"File already exists: {rel_path}", code="already_exists", status=409, details={"vault": vault_name, "path": rel_path}, ) create_backup(file_path, vault_name, rel_path) try: file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_bytes(content) except PermissionError as e: raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e logger.info(f"Raw file saved: {vault_name}/{rel_path} ({len(content)} bytes)") return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} def batch_upload_files( vault_name: str, target_dir: str, files: list[dict[str, Any]], *, overwrite: bool = True, ) -> dict[str, Any]: """Process a batch of uploaded files and directories into a vault. Args: vault_name: Name of the target vault. target_dir: Base directory inside the vault (empty string for root). files: List of dicts, each with: - ``path``: relative path within the batch (e.g. ``"sub/doc.md"`` or ``"note.md"``). - ``content``: bytes content (or base64 decoded). - ``is_dir``: optional boolean for empty directories. overwrite: Whether to overwrite existing files. Returns: Dict with ``uploaded`` (list of paths), ``created_dirs`` (list of paths), and ``errors`` (list of error dicts). """ root = get_vault_root(vault_name) _ensure_writable(root) clean_target = (target_dir or "").strip().strip("/\\") uploaded: list[str] = [] created_dirs: list[str] = [] errors: list[dict[str, Any]] = [] for item in files: rel_subpath = (item.get("path") or "").strip().replace("\\", "/").lstrip("/") if not rel_subpath: continue full_rel_path = f"{clean_target}/{rel_subpath}" if clean_target else rel_subpath is_dir = item.get("is_dir", False) if is_dir: try: dir_path = resolve_safe_path(root, full_rel_path) dir_path.mkdir(parents=True, exist_ok=True) created_dirs.append(_rel(root, dir_path)) except Exception as e: errors.append({"path": full_rel_path, "error": str(e)}) continue raw_bytes = item.get("content", b"") if isinstance(raw_bytes, str): raw_bytes = raw_bytes.encode("utf-8") try: res = save_raw_file(vault_name, full_rel_path, raw_bytes, overwrite=overwrite) uploaded.append(res["path"]) except Exception as e: errors.append({"path": full_rel_path, "error": str(e)}) return { "success": len(errors) == 0, "vault": vault_name, "target_dir": clean_target, "uploaded": uploaded, "created_dirs": created_dirs, "errors": errors, "total_files": len(uploaded), }