Coverage for src/local_deep_research/storage/file.py: 97%
101 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""File-based report storage implementation."""
3import os
4from pathlib import Path
5from typing import Dict, Any, List, Optional
6from loguru import logger
8from .base import ReportStorage
9from ..config.paths import (
10 get_research_outputs_directory,
11 restrict_directory_to_owner,
12)
15class FileReportStorage(ReportStorage):
16 """Store reports as files on disk."""
18 def __init__(self, base_dir: Optional[Path] = None):
19 """Initialize file storage.
21 Args:
22 base_dir: Base directory for storing reports.
23 If None, uses default research outputs directory.
25 Raises:
26 DirectoryNotPrivateError: On POSIX, another OS account owns the
27 directory or its filesystem did not keep mode 0700.
28 OSError: The directory could not be created or restricted.
29 DirectoryCreationSecurityError: The path contains a null byte or
30 a ``..`` segment.
31 """
32 # Lazy import: mirrors the other lazy security imports in this
33 # module (e.g. file_write_verifier) and avoids pulling security
34 # into this module's top-level import graph.
35 from ..security.directory_creation import create_directory
37 self.base_dir = create_directory(
38 base_dir or get_research_outputs_directory(),
39 context="file report storage base directory",
40 mode=0o700,
41 )
42 # The directory may already exist at 0755 from an older install.
43 # Protect legacy backups, and refuse the directory unless it is
44 # private, before any new write.
45 restrict_directory_to_owner(self.base_dir)
47 def _contained_path(self, research_id: str, filename: str) -> Path:
48 """Return ``base_dir / filename`` resolved, or raise ValueError.
50 Both sides are resolved with resolve_allow_missing(), never a bare
51 non-strict resolve(), which can leave part of the path unresolved
52 (a symlink chain whose resolved form outgrows PATH_MAX) and so hide
53 an escape.
54 """
55 from ..security.path_validator import resolve_allow_missing
57 try:
58 candidate = resolve_allow_missing(self.base_dir / filename)
59 base = resolve_allow_missing(self.base_dir)
60 except OSError:
61 raise ValueError(
62 f"Unresolvable report path for research_id: {research_id!r}"
63 )
64 if not candidate.is_relative_to(base):
65 raise ValueError(
66 f"Path traversal attempt with research_id: {research_id!r}"
67 )
68 return candidate
70 def _get_report_path(self, research_id: str) -> Path:
71 """Get the file path for a report."""
72 return self._contained_path(research_id, f"{research_id}.md")
74 def _get_metadata_path(self, research_id: str) -> Path:
75 """Get the file path for report metadata."""
76 return self._contained_path(research_id, f"{research_id}_metadata.json")
78 def save_report(
79 self,
80 research_id: str,
81 content: str,
82 metadata: Optional[Dict[str, Any]] = None,
83 username: Optional[str] = None,
84 ) -> bool:
85 """Save report to file."""
86 try:
87 from ..security.file_write_verifier import (
88 write_file_verified,
89 write_json_verified,
90 )
92 report_path = self._get_report_path(research_id)
94 # Save content
95 write_file_verified(
96 report_path,
97 content,
98 "report.enable_file_backup",
99 context="file storage backup",
100 permissions=0o600 if os.name == "posix" else None,
101 )
103 # Save metadata if provided
104 if metadata:
105 metadata_path = self._get_metadata_path(research_id)
106 write_json_verified(
107 metadata_path,
108 metadata,
109 "report.enable_file_backup",
110 context="file storage metadata",
111 permissions=0o600 if os.name == "posix" else None,
112 )
114 logger.info(
115 f"Saved report for research {research_id} to {report_path}"
116 )
117 return True
119 except Exception:
120 logger.exception("Error saving report to file")
121 return False
123 def get_report(
124 self, research_id: str, username: Optional[str] = None
125 ) -> Optional[str]:
126 """Get report from file."""
127 try:
128 report_path = self._get_report_path(research_id)
130 if not report_path.exists():
131 return None
133 with open(report_path, "r", encoding="utf-8") as f:
134 return f.read()
136 except Exception:
137 logger.exception("Error reading report from file")
138 return None
140 def get_report_with_metadata(
141 self, research_id: str, username: Optional[str] = None
142 ) -> Optional[Dict[str, Any]]:
143 """Get report with metadata from files."""
144 try:
145 content = self.get_report(research_id)
146 if not content:
147 return None
149 result = {"content": content, "metadata": {}}
151 # Try to load metadata
152 metadata_path = self._get_metadata_path(research_id)
153 if metadata_path.exists():
154 import json
156 with open(metadata_path, "r", encoding="utf-8") as f:
157 result["metadata"] = json.load(f)
159 return result
161 except Exception:
162 logger.exception("Error getting report with metadata")
163 return None
165 def list_reports(
166 self, username: Optional[str] = None
167 ) -> List[Dict[str, Any]]:
168 """List reports from file system."""
169 try:
170 from datetime import datetime, timezone
172 reports = []
173 for report_path in self.base_dir.glob("*.md"):
174 research_id = report_path.stem
175 metadata = self._load_metadata(research_id)
176 reports.append(
177 {
178 "id": research_id,
179 "query": metadata.get("query") if metadata else None,
180 "mode": metadata.get("mode", "unknown")
181 if metadata
182 else "unknown",
183 "created_at": datetime.fromtimestamp(
184 report_path.stat().st_mtime, tz=timezone.utc
185 ).isoformat(),
186 "completed_at": metadata.get("completed_at")
187 if metadata
188 else None,
189 }
190 )
191 return reports
192 except Exception:
193 logger.exception("Error listing reports from file system")
194 return []
196 def _load_metadata(self, research_id: str) -> Optional[Dict[str, Any]]:
197 """Load metadata JSON for a report if it exists."""
198 try:
199 metadata_path = self._get_metadata_path(research_id)
200 if metadata_path.exists():
201 import json
203 with open(metadata_path, "r", encoding="utf-8") as f:
204 return json.load(f)
205 except Exception:
206 logger.debug(f"Failed to load metadata for {research_id}")
207 return None
209 def delete_report(
210 self, research_id: str, username: Optional[str] = None
211 ) -> bool:
212 """Delete report files."""
213 try:
214 report_path = self._get_report_path(research_id)
215 metadata_path = self._get_metadata_path(research_id)
217 deleted = False
219 if report_path.exists():
220 report_path.unlink()
221 deleted = True
223 if metadata_path.exists():
224 metadata_path.unlink()
226 return deleted
228 except Exception:
229 logger.exception("Error deleting report files")
230 return False
232 def report_exists(
233 self, research_id: str, username: Optional[str] = None
234 ) -> bool:
235 """Check if report file exists."""
236 return self._get_report_path(research_id).exists()