Coverage for src/local_deep_research/research_library/services/pdf_storage_manager.py: 94%

211 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2PDF Storage Manager for Research Library 

3 

4Handles PDF storage across three modes: 

5- none: Don't store PDFs (text-only) 

6- filesystem: Store PDFs unencrypted on disk (fast, external tool compatible) 

7- database: Store PDFs encrypted in SQLCipher database (secure, portable) 

8""" 

9 

10import hashlib 

11import re 

12from datetime import datetime, UTC 

13from pathlib import Path 

14from typing import Optional, Tuple 

15from urllib.parse import urlparse 

16 

17from loguru import logger 

18from sqlalchemy.orm import Session 

19 

20from ...constants import FILE_PATH_SENTINELS 

21from ...database.models.library import Document, DocumentBlob 

22from ...security.path_validator import PathValidator 

23 

24 

25# Default storage cap for individual PDFs (megabytes). Mirrors the 

26# upload-validator cap (`FileUploadValidator.MAX_FILE_SIZE`, configurable 

27# via `LDR_SECURITY_UPLOAD_MAX_FILE_SIZE_MB`) so a file that passes the 

28# upload step won't be silently dropped at storage time. The runtime 

29# value comes from the `research_library.max_pdf_size_mb` setting; this 

30# constant is the shared fallback used by every code-level default so the 

31# limit doesn't drift across files. 

32DEFAULT_MAX_PDF_SIZE_MB = 3072 # 3 GB 

33 

34 

35def filesystem_pdf_storage_allowed() -> bool: 

36 """Return whether the operator enabled unencrypted filesystem PDF storage. 

37 

38 Environment-only operator gate mirroring 

39 ``policy.allow_unprotected_egress``: the ``filesystem`` mode writes 

40 library-downloaded third-party PDFs as PLAINTEXT to disk (cleartext 

41 storage of sensitive information, CWE-312), so it is disabled by default 

42 and cannot be turned on through the user-writable settings API — only 

43 via the environment variable 

44 ``LDR_RESEARCH_LIBRARY_ALLOW_FILESYSTEM_PDF_STORAGE=true``. 

45 """ 

46 from ...settings.env_registry import get_env_setting 

47 

48 return bool( 

49 get_env_setting("research_library.allow_filesystem_pdf_storage", False) 

50 ) 

51 

52 

53def resolve_pdf_storage_mode(mode): 

54 """Resolve the effective PDF storage mode, enforcing the operator gate. 

55 

56 This is the runtime enforcement point (PEP) for the unencrypted-storage 

57 gate: hiding the ``filesystem`` option in the settings UI is not enough, 

58 because a value stored before the gate existed — or an 

59 ``LDR_RESEARCH_LIBRARY_PDF_STORAGE_MODE=filesystem`` env override — 

60 could still resolve to ``filesystem``. Callers that are about to WRITE 

61 a PDF must resolve the mode through here first. 

62 

63 When the mode is ``filesystem`` and the gate is off, it is coerced to 

64 the encrypted ``database`` default. Reads are unaffected: 

65 ``PDFStorageManager.load_pdf`` checks the database first and then falls 

66 back to the filesystem regardless of ``storage_mode``, so any 

67 previously-written plaintext files stay readable after coercion. 

68 """ 

69 if ( 

70 isinstance(mode, str) 

71 and mode.strip().lower() == "filesystem" 

72 and not filesystem_pdf_storage_allowed() 

73 ): 

74 logger.warning( 

75 "Unencrypted filesystem PDF storage is operator-gated and " 

76 "disabled; coercing pdf_storage_mode 'filesystem' -> 'database' " 

77 "(set LDR_RESEARCH_LIBRARY_ALLOW_FILESYSTEM_PDF_STORAGE=true to " 

78 "opt in)." 

79 ) 

80 return "database" 

81 return mode 

82 

83 

84class PDFStorageManager: 

85 """Unified interface for PDF storage across all modes.""" 

86 

87 def __init__( 

88 self, 

89 library_root: Path, 

90 storage_mode: str, 

91 max_pdf_size_mb: int = DEFAULT_MAX_PDF_SIZE_MB, 

92 legacy_root: Optional[Path] = None, 

93 username: Optional[str] = None, 

94 ): 

95 """ 

96 Initialize PDF storage manager. 

97 

98 Args: 

99 library_root: Base directory for filesystem storage. With per-user 

100 library isolation (issue #5521) this is the per-user 

101 directory; new PDFs are always WRITTEN here. 

102 storage_mode: One of 'none', 'filesystem', 'database' 

103 max_pdf_size_mb: Maximum PDF file size in MB. Should not 

104 exceed `FileUploadValidator.MAX_FILE_SIZE` (the upload 

105 validator's per-file cap, default 3 GB) — uploads above 

106 that cap are rejected before they reach this layer. 

107 legacy_root: Optional legacy shared root to fall back to when a 

108 file is not found under ``library_root``. Lets PDFs 

109 downloaded before per-user isolation still load; never 

110 written to. Ignored when equal to ``library_root``. 

111 username: Owning user for destructive operations. When falsy, 

112 ``library_root`` cannot be guaranteed to be a per-user root 

113 (``apply_user_subdir`` returns the bare shared root for an 

114 empty username), so ``delete_pdf`` fails closed rather than 

115 unlink within a possibly-shared directory. 

116 """ 

117 self.library_root = Path(library_root).resolve() 

118 self.storage_mode = storage_mode 

119 self.username = username 

120 self.max_pdf_size_bytes = max_pdf_size_mb * 1024 * 1024 

121 resolved_legacy = ( 

122 Path(legacy_root).resolve() if legacy_root is not None else None 

123 ) 

124 # Only keep a distinct legacy root — a legacy root equal to the 

125 # per-user root is a no-op and would double-check the same directory. 

126 self.legacy_root = ( 

127 resolved_legacy 

128 if resolved_legacy is not None 

129 and resolved_legacy != self.library_root 

130 else None 

131 ) 

132 

133 if storage_mode not in ("none", "filesystem", "database"): 

134 logger.warning( 

135 f"Unknown storage mode '{storage_mode}', defaulting to 'none'" 

136 ) 

137 self.storage_mode = "none" 

138 

139 def _safe_path_in_root( 

140 self, relative_path: str, root: Path 

141 ) -> Optional[Path]: 

142 """Validate ``relative_path`` inside ``root`` (traversal + symlink 

143 safe). Returns the validated Path (which may not exist), or None if 

144 the path is invalid/unsafe.""" 

145 try: 

146 # Use PathValidator to safely join and validate the path 

147 safe_path = PathValidator.validate_safe_path( 

148 relative_path, str(root) 

149 ) 

150 safe_path = Path(safe_path) 

151 # Block symbolic links to prevent symlink-based escapes 

152 if safe_path.is_symlink(): 

153 logger.warning(f"Symlink blocked: {relative_path}") 

154 return None 

155 return safe_path 

156 except ValueError: 

157 logger.warning(f"Path traversal blocked: {relative_path}") 

158 return None 

159 

160 def _get_safe_file_path( 

161 self, relative_path: str, *, allow_legacy_fallback: bool = True 

162 ) -> Optional[Path]: 

163 """ 

164 Safely resolve a relative path within the library root. 

165 

166 Prevents path traversal attacks by validating the path stays within 

167 the library root directory. Resolves against the per-user root first 

168 and, when the file is absent there, falls back to the legacy shared 

169 root (issue #5521) so pre-isolation downloads still load. When 

170 neither location has the file, the per-user path is returned so 

171 callers' ``is_file()``/``None`` handling is unchanged. 

172 

173 ``allow_legacy_fallback`` must be ``False`` for destructive callers 

174 (unlink): the legacy shared root is not per-user namespaced, so a 

175 colliding relative path can resolve to another tenant's file and be 

176 unlinked. Delete only within this manager's own ``library_root``. 

177 

178 Even for read-only callers the legacy shared-root fallback is 

179 cross-tenant read primitive on a multi-tenant instance (the shared 

180 root is derived from the user-editable ``research_library.storage_path`` 

181 and per-user resource ids collide by construction), so it only fires 

182 when the operator opted into it via 

183 ``research_library.allow_legacy_read_fallback`` — OFF by default. 

184 

185 Args: 

186 relative_path: Relative path from database 

187 allow_legacy_fallback: Permit the read-only legacy shared-root 

188 fallback. Pass ``False`` from delete/unlink paths. Even when 

189 ``True``, the fallback only fires if the operator enabled 

190 ``research_library.allow_legacy_read_fallback``. 

191 

192 Returns: 

193 Validated absolute Path or None if path is invalid/unsafe 

194 """ 

195 if not relative_path or relative_path in FILE_PATH_SENTINELS: 

196 return None 

197 

198 primary = self._safe_path_in_root(relative_path, self.library_root) 

199 if primary is not None and primary.is_file(): 

200 return primary 

201 # Legacy shared-root fallback for files downloaded before per-user 

202 # isolation. Never written to; read-only fallback. Enforced at the 

203 # fallback-USE site (view_pdf_page passes legacy_root=base_root 

204 # explicitly) so the operator gate covers every caller: only follow 

205 # the legacy root when the caller permits it AND the operator opted in. 

206 from ..utils import _legacy_read_fallback_allowed 

207 

208 if ( 

209 allow_legacy_fallback 

210 and self.legacy_root is not None 

211 and _legacy_read_fallback_allowed() 

212 ): 

213 legacy = self._safe_path_in_root(relative_path, self.legacy_root) 

214 if legacy is not None and legacy.is_file(): 214 ↛ 216line 214 didn't jump to line 216 because the condition on line 214 was always true

215 return legacy 

216 return primary 

217 

218 def save_pdf( 

219 self, 

220 pdf_content: bytes, 

221 document: Document, 

222 session: Session, 

223 filename: str, 

224 url: Optional[str] = None, 

225 resource_id: Optional[int] = None, 

226 ) -> Tuple[Optional[str], int]: 

227 """ 

228 Save PDF based on configured storage mode. 

229 

230 Args: 

231 pdf_content: Raw PDF bytes 

232 document: Document model instance 

233 session: Database session 

234 filename: Filename to use for saving 

235 url: Source URL (for generating better filenames) 

236 resource_id: Resource ID (for generating better filenames) 

237 

238 Returns: 

239 Tuple of (file_path or storage indicator, file_size) 

240 - For filesystem: relative path string 

241 - For database: "database" 

242 - For none: None 

243 """ 

244 file_size = len(pdf_content) 

245 

246 # Check file size limit 

247 if file_size > self.max_pdf_size_bytes: 

248 max_mb = self.max_pdf_size_bytes / (1024 * 1024) 

249 logger.warning( 

250 f"PDF size ({file_size / (1024 * 1024):.1f}MB) exceeds limit " 

251 f"({max_mb:.0f}MB), skipping storage" 

252 ) 

253 return None, file_size 

254 

255 if self.storage_mode == "none": 

256 logger.debug("PDF storage mode is 'none' - skipping PDF save") 

257 return None, file_size 

258 

259 if self.storage_mode == "filesystem": 

260 file_path = self._save_to_filesystem( 

261 pdf_content, filename, url, resource_id 

262 ) 

263 relative_path = str(file_path.relative_to(self.library_root)) 

264 document.storage_mode = "filesystem" 

265 document.file_path = relative_path 

266 logger.info(f"PDF saved to filesystem: {relative_path}") 

267 return relative_path, file_size 

268 

269 if self.storage_mode == "database": 269 ↛ 276line 269 didn't jump to line 276 because the condition on line 269 was always true

270 self._save_to_database(pdf_content, document, session) 

271 document.storage_mode = "database" 

272 document.file_path = None # No filesystem path 

273 logger.info(f"PDF saved to database for document {document.id}") 

274 return "database", file_size 

275 

276 return None, file_size 

277 

278 def load_pdf(self, document: Document, session: Session) -> Optional[bytes]: 

279 """ 

280 Load PDF - check database first, then filesystem. 

281 

282 Smart retrieval: doesn't rely on storage_mode column, actually checks 

283 where the PDF exists. 

284 

285 Args: 

286 document: Document model instance 

287 session: Database session 

288 

289 Returns: 

290 PDF bytes or None if not available 

291 """ 

292 # 1. Check database first 

293 pdf_bytes = self._load_from_database(document, session) 

294 if pdf_bytes: 

295 logger.debug(f"Loaded PDF from database for document {document.id}") 

296 return pdf_bytes 

297 

298 # 2. Fallback to filesystem 

299 pdf_bytes = self._load_from_filesystem(document) 

300 if pdf_bytes: 

301 logger.debug( 

302 f"Loaded PDF from filesystem for document {document.id}" 

303 ) 

304 return pdf_bytes 

305 

306 logger.debug(f"No PDF available for document {document.id}") 

307 return None 

308 

309 def has_pdf(self, document: Document, session: Session) -> bool: 

310 """ 

311 Check if PDF is available without loading the actual bytes. 

312 

313 Args: 

314 document: Document model instance 

315 session: Database session 

316 

317 Returns: 

318 True if PDF is available (in database or filesystem) 

319 """ 

320 # Must be a PDF file type 

321 if document.file_type != "pdf": 

322 return False 

323 

324 # Check database first (has blob?) 

325 from ...database.models.library import DocumentBlob 

326 

327 has_blob = ( 

328 session.query(DocumentBlob.document_id) 

329 .filter_by(document_id=document.id) 

330 .first() 

331 is not None 

332 ) 

333 if has_blob: 

334 return True 

335 

336 # Check filesystem (with path traversal protection) 

337 file_path = self._get_safe_file_path(document.file_path) 

338 if file_path and file_path.is_file(): 

339 return True 

340 

341 return False 

342 

343 @classmethod 

344 def pdf_exists(cls, library_root, document, session, legacy_root=None): 

345 """Check if a PDF exists in any storage backend. 

346 

347 Use this when you need to check PDF availability without a specific 

348 storage mode — e.g. generating document URLs in search results. 

349 ``legacy_root`` enables the per-user -> legacy-shared read fallback 

350 (issue #5521) so a pre-isolation filesystem PDF still reports present. 

351 """ 

352 manager = cls(library_root, "none", legacy_root=legacy_root) 

353 return manager.has_pdf(document, session) 

354 

355 def _infer_storage_mode(self, document: Document) -> str: 

356 """ 

357 Infer storage mode for documents without explicit mode set. 

358 Used for backward compatibility with existing documents. 

359 """ 

360 # If there's a blob, it's database storage 

361 if hasattr(document, "blob") and document.blob: 

362 return "database" 

363 # If there's a file_path (and not a sentinel), it's filesystem 

364 if document.file_path and document.file_path not in FILE_PATH_SENTINELS: 

365 return "filesystem" 

366 # Otherwise no storage 

367 return "none" 

368 

369 def _save_to_filesystem( 

370 self, 

371 pdf_content: bytes, 

372 filename: str, 

373 url: Optional[str] = None, 

374 resource_id: Optional[int] = None, 

375 ) -> Path: 

376 """ 

377 Save PDF to filesystem with organized structure. 

378 

379 Returns: 

380 Absolute path to saved file 

381 """ 

382 # Generate better filename if URL is provided 

383 if url: 

384 filename = self._generate_filename(url, resource_id, filename) 

385 

386 # Create simple flat directory structure - all PDFs in one folder 

387 from ...security.directory_creation import create_directory 

388 

389 pdf_path = self.library_root / "pdfs" 

390 create_directory( 

391 pdf_path, 

392 context="library PDF storage directory", 

393 ) 

394 

395 # Use PathValidator with relative path from library_root 

396 relative_path = f"pdfs/{filename}" 

397 validated_path = PathValidator.validate_safe_path( 

398 relative_path, 

399 base_dir=str(self.library_root), 

400 required_extensions=(".pdf",), 

401 ) 

402 

403 # Write the PDF file with security verification 

404 # Pass current storage_mode as snapshot since we already validated it 

405 from ...security.file_write_verifier import write_file_verified 

406 

407 write_file_verified( 

408 validated_path, 

409 pdf_content, 

410 "research_library.pdf_storage_mode", 

411 "filesystem", 

412 "library PDF storage", 

413 mode="wb", 

414 settings_snapshot={ 

415 "research_library.pdf_storage_mode": self.storage_mode 

416 }, 

417 ) 

418 

419 return Path(validated_path) 

420 

421 def _save_to_database( 

422 self, pdf_content: bytes, document: Document, session: Session 

423 ) -> None: 

424 """Store PDF in document_blobs table.""" 

425 # Check if blob already exists 

426 existing_blob = ( 

427 session.query(DocumentBlob) 

428 .filter_by(document_id=document.id) 

429 .first() 

430 ) 

431 

432 if existing_blob: 

433 # Update existing blob 

434 existing_blob.pdf_binary = pdf_content 

435 existing_blob.blob_hash = hashlib.sha256(pdf_content).hexdigest() 

436 existing_blob.stored_at = datetime.now(UTC) 

437 logger.debug(f"Updated existing blob for document {document.id}") 

438 else: 

439 # Create new blob 

440 blob = DocumentBlob( 

441 document_id=document.id, 

442 pdf_binary=pdf_content, 

443 blob_hash=hashlib.sha256(pdf_content).hexdigest(), 

444 stored_at=datetime.now(UTC), 

445 ) 

446 session.add(blob) 

447 logger.debug(f"Created new blob for document {document.id}") 

448 

449 def _load_from_filesystem(self, document: Document) -> Optional[bytes]: 

450 """Load PDF from filesystem with path traversal protection.""" 

451 # Use safe path resolution to prevent path traversal attacks 

452 file_path = self._get_safe_file_path(document.file_path) 

453 if not file_path: 

454 return None 

455 

456 if not file_path.is_file(): 

457 logger.warning(f"PDF file not found: {file_path}") 

458 return None 

459 

460 try: 

461 return file_path.read_bytes() 

462 except Exception: 

463 logger.exception(f"Failed to read PDF from {file_path}") 

464 return None 

465 

466 def _load_from_database( 

467 self, document: Document, session: Session 

468 ) -> Optional[bytes]: 

469 """Load PDF from document_blobs table.""" 

470 blob = ( 

471 session.query(DocumentBlob) 

472 .filter_by(document_id=document.id) 

473 .first() 

474 ) 

475 

476 if not blob: 

477 logger.debug(f"No blob found for document {document.id}") 

478 return None 

479 

480 # Update last accessed timestamp 

481 blob.last_accessed = datetime.now(UTC) 

482 

483 return blob.pdf_binary 

484 

485 def _generate_filename( 

486 self, url: str, resource_id: Optional[int], fallback_filename: str 

487 ) -> str: 

488 """Generate a meaningful filename from URL.""" 

489 parsed_url = urlparse(url) 

490 hostname = parsed_url.hostname or "" 

491 timestamp = datetime.now(UTC).strftime("%Y%m%d") 

492 

493 if hostname == "arxiv.org" or hostname.endswith(".arxiv.org"): 

494 # Extract arXiv ID 

495 match = re.search(r"(\d{4}\.\d{4,5})", url) 

496 if match: 

497 return f"arxiv_{match.group(1)}.pdf" 

498 return f"arxiv_{timestamp}_{resource_id or 'unknown'}.pdf" 

499 

500 if hostname == "ncbi.nlm.nih.gov" and "/pmc" in parsed_url.path: 

501 # Extract PMC ID 

502 match = re.search(r"(PMC\d+)", url) 

503 if match: 

504 return f"pmc_{match.group(1)}.pdf" 

505 return f"pubmed_{timestamp}_{resource_id or 'unknown'}.pdf" 

506 

507 # Use fallback filename 

508 return fallback_filename 

509 

510 def delete_pdf(self, document: Document, session: Session) -> bool: 

511 """ 

512 Delete PDF for a document. 

513 

514 Args: 

515 document: Document model instance 

516 session: Database session 

517 

518 Returns: 

519 True if deletion succeeded 

520 """ 

521 storage_mode = document.storage_mode or self._infer_storage_mode( 

522 document 

523 ) 

524 

525 try: 

526 if storage_mode == "filesystem": 

527 # Fail closed without a user context: an empty username means 

528 # library_root may be the bare shared root (apply_user_subdir 

529 # returns the shared base for an empty username), where a 

530 # colliding relative path can belong to another tenant. Refuse 

531 # the unlink rather than risk deleting another user's file. 

532 if not self.username: 

533 logger.warning( 

534 "Refusing filesystem PDF delete for document " 

535 f"{document.id}: no user context, cannot confirm the " 

536 "library root is per-user (would risk unlinking a " 

537 "shared-root file)." 

538 ) 

539 return False 

540 # Use safe path resolution to prevent path traversal attacks. 

541 # Delete within our own root only — never the shared legacy 

542 # root, where a colliding path can be another tenant's file. 

543 file_path = self._get_safe_file_path( 

544 document.file_path, allow_legacy_fallback=False 

545 ) 

546 if file_path and file_path.is_file(): 

547 file_path.unlink() 

548 logger.info(f"Deleted PDF file: {file_path}") 

549 document.file_path = None 

550 document.storage_mode = "none" 

551 return True 

552 

553 if storage_mode == "database": 

554 blob = ( 

555 session.query(DocumentBlob) 

556 .filter_by(document_id=document.id) 

557 .first() 

558 ) 

559 if blob: 559 ↛ 562line 559 didn't jump to line 562 because the condition on line 559 was always true

560 session.delete(blob) 

561 logger.info(f"Deleted PDF blob for document {document.id}") 

562 document.storage_mode = "none" 

563 return True 

564 

565 return True # Nothing to delete for 'none' mode 

566 

567 except Exception: 

568 logger.exception(f"Failed to delete PDF for document {document.id}") 

569 return False 

570 

571 def upgrade_to_pdf( 

572 self, document: Document, pdf_content: bytes, session: Session 

573 ) -> bool: 

574 """ 

575 Upgrade a text-only document to include PDF storage. 

576 

577 If document already has a PDF stored, returns False (no action needed). 

578 If document is text-only, adds the PDF blob and updates storage_mode. 

579 

580 Args: 

581 document: Document model instance 

582 pdf_content: Raw PDF bytes 

583 session: Database session 

584 

585 Returns: 

586 True if PDF was added, False if already had PDF or failed 

587 """ 

588 # Only upgrade if document is currently text-only 

589 if document.storage_mode not in (None, "none"): 

590 logger.debug( 

591 f"Document {document.id} already has storage_mode={document.storage_mode}" 

592 ) 

593 return False 

594 

595 # Check if blob already exists (shouldn't happen, but be safe) 

596 existing_blob = ( 

597 session.query(DocumentBlob) 

598 .filter_by(document_id=document.id) 

599 .first() 

600 ) 

601 if existing_blob: 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true

602 logger.debug(f"Document {document.id} already has a blob") 

603 return False 

604 

605 # Check file size 

606 file_size = len(pdf_content) 

607 if file_size > self.max_pdf_size_bytes: 

608 max_mb = self.max_pdf_size_bytes / (1024 * 1024) 

609 logger.warning( 

610 f"PDF size ({file_size / (1024 * 1024):.1f}MB) exceeds limit " 

611 f"({max_mb:.0f}MB), skipping upgrade" 

612 ) 

613 return False 

614 

615 try: 

616 # Add the PDF blob 

617 self._save_to_database(pdf_content, document, session) 

618 document.storage_mode = "database" 

619 document.file_path = None 

620 logger.info(f"Upgraded document {document.id} with PDF blob") 

621 return True 

622 except Exception: 

623 logger.exception( 

624 f"Failed to upgrade document {document.id} with PDF" 

625 ) 

626 return False