from __future__ import annotations import json import os import stat from pathlib import Path, PurePosixPath from typing import Any from gfa_core.mas.job_path_resolver import ( job_authorized_data_roots, resolve_allowed_external_path, ) MAX_PREVIEW_BYTES = 65_536 DEFAULT_MAX_LINES = 200 MAX_PREVIEW_LINES = 500 MAX_LINE_CHARS = 16_384 DEFAULT_LIST_LIMIT = 200 MAX_LIST_LIMIT = 500 DEFAULT_LIST_ROOTS = ("input", "final", "logs") _LIST_ROOTS = {"input", "final", "logs", "workspace"} _ALLOWED_SUFFIXES = { ".bed", ".csv", ".fa", ".faa", ".fasta", ".fna", ".gff", ".gff3", ".json", ".jsonl", ".log", ".md", ".newick", ".nwk", ".tab", ".text", ".tsv", ".txt", ".yaml", ".yml", } _DENIED_SUFFIXES = {".key", ".p12", ".pem", ".pfx"} _DENIED_PREFIXES = ("credentials", "id_rsa", "secrets") _DENIED_SUBSTRINGS = ("private_key", "token") def _error(code: str, message: str) -> dict[str, Any]: return {"success": False, "error_code": code, "message": message} def _valid_job_id(job_id: str) -> bool: value = str(job_id or "") return bool(value) and value not in {".", ".."} and not Path(value).is_absolute() and "/" not in value and "\\" not in value def _path_is_sensitive(parts: tuple[str, ...]) -> bool: for part in parts: lower = part.lower() suffix = Path(lower).suffix if not part or part in {".", ".."} or part.startswith("."): return True if lower == ".env" or suffix in _DENIED_SUFFIXES: return True if lower.startswith(_DENIED_PREFIXES) or any(token in lower for token in _DENIED_SUBSTRINGS): return True return False def _parse_qualified_path(requested_path: str) -> tuple[str, tuple[str, ...]] | None: raw = str(requested_path or "").strip().replace("\\", "/") pure = PurePosixPath(raw) if not raw or pure.is_absolute(): return None parts = pure.parts if len(parts) < 2 or parts[0] not in {"input", "workspace", "final"}: return None children = tuple(parts[1:]) if _path_is_sensitive(children): return None if parts[0] == "workspace" and children and children[0] == "final": return "final", tuple(children[1:]) return parts[0], children def _parse_requested_path( project_root: Path, job_id: str, requested_path: str, ) -> tuple[str, tuple[str, ...]] | None: raw = str(requested_path or "").strip() if not Path(raw).is_absolute(): return _parse_qualified_path(raw) logical_path = Path(os.path.abspath(raw)) roots = ( ("input", _root_path(project_root, job_id, "input")), ("final", _root_path(project_root, job_id, "final")), ("workspace", _root_path(project_root, job_id, "workspace")), ) for root_name, root in roots: try: relative = logical_path.relative_to(Path(os.path.abspath(root))) except ValueError: continue parts = tuple(relative.parts) if parts and not _path_is_sensitive(parts): return root_name, parts return None def _root_path(project_root: Path, job_id: str, root_name: str) -> Path: if root_name == "input": return project_root / "input" / job_id if root_name == "final": return project_root / "workspace" / job_id / "final" return project_root / "workspace" / job_id def _unique_basename(project_root: Path, job_id: str, basename: str) -> tuple[str, tuple[str, ...]] | dict[str, Any]: if "/" in basename or "\\" in basename or _path_is_sensitive((basename,)): return _error("path_not_allowed", "Use a safe current-job relative file path.") matches: list[tuple[str, tuple[str, ...]]] = [] for root_name in ("input", "workspace"): root = _root_path(project_root, job_id, root_name) if not root.is_dir() or root.is_symlink(): continue for current, directories, files in os.walk(root, followlinks=False): directories[:] = [name for name in directories if not (Path(current) / name).is_symlink()] if basename not in files: continue relative = (Path(current) / basename).relative_to(root).parts if root_name == "workspace" and relative and relative[0] == "final": matches.append(("final", tuple(relative[1:]))) else: matches.append((root_name, tuple(relative))) unique = list(dict.fromkeys(matches)) if not unique: return _error("file_not_found", "No current-job file matches that path.") if len(unique) > 1: return _error( "ambiguous_path", "Multiple current-job files match that basename; use a root-qualified path.", ) return unique[0] def _open_root_descriptor(project_root: Path, job_id: str, root_name: str) -> int: directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) if root_name == "input": parent = project_root / "input" tail = (job_id,) else: parent = project_root / "workspace" tail = (job_id,) + (("final",) if root_name == "final" else ()) descriptor = os.open(parent, directory_flags) try: for component in tail: next_descriptor = os.open(component, directory_flags, dir_fd=descriptor) os.close(descriptor) descriptor = next_descriptor return descriptor except Exception: os.close(descriptor) raise def _open_file_descriptor(project_root: Path, job_id: str, root_name: str, parts: tuple[str, ...]) -> int: if not parts or _path_is_sensitive(parts): raise PermissionError("unsafe path") directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) descriptor = _open_root_descriptor(project_root, job_id, root_name) try: for component in parts[:-1]: next_descriptor = os.open(component, directory_flags, dir_fd=descriptor) os.close(descriptor) descriptor = next_descriptor file_descriptor = os.open(parts[-1], file_flags, dir_fd=descriptor) info = os.fstat(file_descriptor) if not stat.S_ISREG(info.st_mode): os.close(file_descriptor) raise PermissionError("not a regular file") return file_descriptor finally: os.close(descriptor) def _open_authorized_link(project_root: Path, job_id: str, root_name: str, parts: tuple[str, ...]) -> int: logical_path = _root_path(project_root, job_id, root_name).joinpath(*parts) if not logical_path.is_symlink(): raise PermissionError("not an authorized file link") try: target = resolve_allowed_external_path( logical_path, additional_roots=job_authorized_data_roots( Path(project_root) / "input" / job_id ), ) except ValueError as exc: raise PermissionError("external target is not authorized") from exc file_descriptor = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(file_descriptor) if not stat.S_ISREG(info.st_mode): os.close(file_descriptor) raise PermissionError("not a regular file") return file_descriptor def _read_prefix(descriptor: int) -> bytes: chunks: list[bytes] = [] remaining = MAX_PREVIEW_BYTES + 1 while remaining > 0: chunk = os.read(descriptor, min(16_384, remaining)) if not chunk: break chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def _artifact_bindings(final_dir: Path, workspace_dir: Path) -> dict[Path, dict[str, str]]: bindings: dict[Path, dict[str, str]] = {} if not final_dir.is_dir(): return bindings for manifest_path in sorted(final_dir.glob("*.manifest.json")): try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): continue stage = str(manifest.get("stage") or "") status = str(manifest.get("status") or "") bindings[manifest_path.absolute()] = { "stage": stage, "status": status, "artifact_key": "manifest", } artifacts = manifest.get("artifacts") if not isinstance(artifacts, dict): continue for artifact_key, artifact in artifacts.items(): if not isinstance(artifact, dict): continue raw_path = str(artifact.get("full_path") or artifact.get("path") or "").strip() if not raw_path: continue candidate = Path(raw_path) if not candidate.is_absolute(): candidate = workspace_dir / candidate bindings[candidate.absolute()] = { "stage": stage, "status": status, "artifact_key": str(artifact_key), } return bindings def _inventory_root( project_root: Path, job_id: str, root_name: str, ) -> tuple[Path, str]: if root_name == "input": return _root_path(project_root, job_id, "input"), "input" if root_name == "final": return _root_path(project_root, job_id, "final"), "final" if root_name == "logs": return _root_path(project_root, job_id, "workspace") / "logs", "workspace/logs" return _root_path(project_root, job_id, "workspace"), "workspace" def list_job_files( project_root: Path, job_id: str, *, roots: tuple[str, ...] = DEFAULT_LIST_ROOTS, limit: int = DEFAULT_LIST_LIMIT, cursor: int = 0, ) -> dict[str, Any]: project_root = Path(project_root) if not _valid_job_id(job_id): return _error("invalid_job_id", "A valid current job is required.") selected_roots = tuple(dict.fromkeys(str(root) for root in roots)) if not selected_roots or any(root not in _LIST_ROOTS for root in selected_roots): return _error("invalid_root", "Use input, final, logs, or workspace.") bounded_limit = min(max(int(limit), 1), MAX_LIST_LIMIT) bounded_cursor = max(int(cursor), 0) workspace_dir = _root_path(project_root, job_id, "workspace") bindings = _artifact_bindings(workspace_dir / "final", workspace_dir) items: list[dict[str, Any]] = [] for root_name in selected_roots: root, handle_prefix = _inventory_root(project_root, job_id, root_name) if not root.is_dir() or root.is_symlink(): continue for current, directories, files in os.walk(root, followlinks=False): current_path = Path(current) directories[:] = sorted( name for name in directories if not (current_path / name).is_symlink() and not ( root_name == "workspace" and current_path == root and name in {"final", "logs"} ) ) for filename in sorted(files): path = current_path / filename relative_parts = path.relative_to(root).parts if _path_is_sensitive(tuple(relative_parts)): continue try: if path.is_symlink(): target = resolve_allowed_external_path( path, additional_roots=job_authorized_data_roots( project_root / "input" / job_id ), ) size = target.stat().st_size else: size = path.stat().st_size except (OSError, ValueError): continue handle = f"{handle_prefix}/{'/'.join(relative_parts)}" binding = bindings.get(path.absolute(), {}) items.append( { "file_handle": handle, "root": root_name, "name": filename, "suffix": path.suffix.lower(), "size": size, "stage": binding.get("stage", ""), "status": binding.get("status", ""), "artifact_key": binding.get("artifact_key", ""), } ) items.sort(key=lambda item: item["file_handle"]) page = items[bounded_cursor : bounded_cursor + bounded_limit] next_cursor = bounded_cursor + len(page) if next_cursor >= len(items): next_cursor = None return { "success": True, "roots": list(selected_roots), "files": page, "total": len(items), "cursor": bounded_cursor, "next_cursor": next_cursor, "truncated": next_cursor is not None, } def read_job_file_preview( project_root: Path, job_id: str, requested_path: str, *, max_lines: int = DEFAULT_MAX_LINES, ) -> dict[str, Any]: project_root = Path(project_root) if not _valid_job_id(job_id): return _error("invalid_job_id", "A valid current job is required.") raw = str(requested_path or "").strip() parsed: tuple[str, tuple[str, ...]] | dict[str, Any] | None if "/" not in raw and "\\" not in raw: parsed = _unique_basename(project_root, job_id, raw) else: parsed = _parse_requested_path(project_root, job_id, raw) if isinstance(parsed, dict): return parsed if parsed is None or not parsed[1]: return _error("path_not_allowed", "Use a safe current-job relative file path.") root_name, parts = parsed suffix = Path(parts[-1]).suffix.lower() try: descriptor = _open_file_descriptor(project_root, job_id, root_name, parts) except FileNotFoundError: return _error("file_not_found", "No current-job file matches that path.") except (NotADirectoryError, OSError, PermissionError): try: descriptor = _open_authorized_link(project_root, job_id, root_name, parts) except (NotADirectoryError, OSError, PermissionError): return _error("path_not_allowed", "Use a safe current-job relative file path.") try: info = os.fstat(descriptor) payload = _read_prefix(descriptor) finally: os.close(descriptor) if b"\x00" in payload: return _error("binary_file", "Binary files cannot be previewed in conversation.") if suffix not in _ALLOWED_SUFFIXES: return _error("unsupported_file_type", "That file type is not allowed for conversation preview.") try: text = payload.decode("utf-8") encoding_warning = False except UnicodeDecodeError: text = payload.decode("utf-8", errors="replace") encoding_warning = True requested_lines = min(max(int(max_lines), 1), MAX_PREVIEW_LINES) observed_lines = text.splitlines() selected = observed_lines[:requested_lines] rendered = [f"{index}: {line[: MAX_LINE_CHARS - 12]}" for index, line in enumerate(selected, 1)] preview = "\n".join(rendered) encoded_preview = preview.encode("utf-8") if len(encoded_preview) > MAX_PREVIEW_BYTES: encoded_preview = encoded_preview[:MAX_PREVIEW_BYTES] preview = encoded_preview.decode("utf-8", errors="ignore") truncated = ( len(payload) > MAX_PREVIEW_BYTES or len(observed_lines) > requested_lines or any(len(line) > MAX_LINE_CHARS - 12 for line in selected) or len(preview.encode("utf-8")) < len("\n".join(rendered).encode("utf-8")) ) handle = f"{root_name}/{'/'.join(parts)}" return { "success": True, "file_handle": handle, "size": info.st_size, "line_count": len(observed_lines), "returned_lines": len(preview.splitlines()), "preview": preview, "preview_bytes": len(preview.encode("utf-8")), "truncated": truncated, "encoding_warning": encoding_warning, }