Coverage for src/local_deep_research/config/paths.py: 99%

106 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Centralized path configuration for Local Deep Research. 

3Handles database location using platformdirs for proper user data storage. 

4""" 

5 

6import errno 

7import hashlib 

8import os 

9import stat 

10from pathlib import Path 

11 

12import platformdirs 

13from loguru import logger 

14 

15 

16def get_data_directory() -> Path: 

17 """ 

18 Get the appropriate data directory for storing application data. 

19 Uses platformdirs to get platform-specific user data directory. 

20 

21 Environment variable: 

22 LDR_DATA_DIR: Override the default data directory location. 

23 All subdirectories (research_outputs, cache, logs, database) 

24 will be created under this directory. 

25 

26 Returns: 

27 Path to data directory 

28 """ 

29 # Check for explicit override via environment variable 

30 custom_path = os.getenv("LDR_DATA_DIR") 

31 if custom_path: 

32 if not Path(custom_path).is_absolute(): 

33 raise ValueError("LDR_DATA_DIR must be an absolute path") 

34 data_dir = Path(custom_path).resolve() 

35 # Reject only control characters that are never valid in a path and 

36 # could break a SQL/ATTACH statement (null byte, newline, carriage 

37 # return). Quotes are intentionally NOT rejected here: legitimate POSIX 

38 # home directories contain apostrophes (e.g. /home/O'Brien/ldr), and the 

39 # actual ATTACH-DATABASE sink (backup_service) already validates the 

40 # full interpolated path against a stricter denylist before use. 

41 # The offending path is not echoed, mirroring the redaction below. 

42 path_str = str(data_dir) 

43 if any(c in path_str for c in ("\0", "\n", "\r")): 

44 raise ValueError( 

45 "LDR_DATA_DIR contains unsafe control characters " 

46 "(null byte, newline, or carriage return)" 

47 ) 

48 logger.debug( 

49 f"Using custom data directory from LDR_DATA_DIR: {data_dir}" 

50 ) 

51 return data_dir 

52 

53 # Use platformdirs for platform-specific user data directory 

54 # Windows: C:\Users\Username\AppData\Local\local-deep-research 

55 # macOS: ~/Library/Application Support/local-deep-research 

56 # Linux: ~/.local/share/local-deep-research 

57 data_dir = Path(platformdirs.user_data_dir("local-deep-research")) 

58 # Log only the directory pattern, not the full path which may contain username 

59 logger.debug( 

60 f"Using platformdirs data directory pattern: .../{data_dir.name}" 

61 ) 

62 

63 return data_dir 

64 

65 

66def get_custom_data_directory() -> Path | None: 

67 """ 

68 Get the data directory that ``LDR_DATA_DIR`` selects, if it is set. 

69 

70 ``None`` means ``LDR_DATA_DIR`` is unset or empty, so 

71 :func:`get_data_directory` uses the platform default. Otherwise the result 

72 is exactly what :func:`get_data_directory` returns, including its 

73 validation errors. ``LDR_DATA_DIR`` is the only environment variable that 

74 moves the data directory, so this is what a status report should show 

75 when it says the location is custom. 

76 

77 Returns: 

78 Path selected by ``LDR_DATA_DIR``, or ``None`` for the platform default 

79 """ 

80 if not os.getenv("LDR_DATA_DIR"): 

81 return None 

82 return get_data_directory() 

83 

84 

85def _ensure_dir( 

86 subdir: str, label: str | None = None, mode: int | None = None 

87) -> Path: 

88 """Create (if needed) and return ``<data_dir>/<subdir>``. 

89 

90 Args: 

91 subdir: Path segment below the data directory. May be multi-segment 

92 (e.g. ``"encrypted_databases/backups"``). 

93 label: If provided, emit a debug log ``"Using {label} directory: 

94 {path}"``. ``None`` suppresses logging. 

95 mode: Optional creation mode for a new directory. 

96 

97 Returns: 

98 The ensured directory path. 

99 """ 

100 path = get_data_directory() / subdir 

101 if mode is None: 

102 path.mkdir( # Safe: bootstrap data subdirectory 

103 parents=True, exist_ok=True 

104 ) 

105 else: 

106 path.mkdir( # Safe: bootstrap data subdirectory 

107 parents=True, exist_ok=True, mode=mode 

108 ) 

109 if label is not None: 

110 logger.debug(f"Using {label} directory: {path}") 

111 return path 

112 

113 

114#: ``DirectoryNotPrivateError.reason`` values. 

115OWNED_BY_ANOTHER_ACCOUNT = "owned_by_another_account" 

116MODE_NOT_KEPT = "mode_not_kept" 

117 

118#: What an operator can do when report file backups are refused. 

119REPORT_BACKUP_REMEDY = ( 

120 "Keep the data directory (LDR_DATA_DIR) on a Linux filesystem path or a " 

121 "Docker named volume owned by the account that runs Local Deep Research, " 

122 "or turn off report.enable_file_backup" 

123) 

124 

125 

126class DirectoryNotPrivateError(PermissionError): 

127 """A directory cannot be restricted to the current OS account. 

128 

129 ``reason`` is ``OWNED_BY_ANOTHER_ACCOUNT`` or ``MODE_NOT_KEPT``. 

130 """ 

131 

132 def __init__(self, message: str, reason: str): 

133 super().__init__(message) 

134 self.reason = reason 

135 

136 

137def restrict_directory_to_owner(path: Path) -> None: 

138 """Set ``path`` to mode 0700 on POSIX and verify the result. 

139 

140 Other systems have no POSIX mode bits, so this does nothing there; the 

141 directory's access-control list decides who can read it. 

142 

143 The result is checked because some mounts accept chmod without keeping 

144 it, or show another owner: Docker Desktop bind mounts of Windows 

145 folders, WSL drives such as /mnt/c, CIFS/SMB shares and exFAT. 

146 

147 Raises: 

148 DirectoryNotPrivateError: another OS account owns the directory, or 

149 its mode is not exactly 0700 after chmod. 

150 OSError: chmod or stat failed for another reason. 

151 """ 

152 if os.name != "posix": 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 return 

154 try: 

155 path.chmod(0o700) 

156 except PermissionError: 

157 # Only the owner may chmod. Report a foreign owner as such below; 

158 # any other cause propagates unchanged. 

159 if path.stat().st_uid == os.geteuid(): 

160 raise 

161 info = path.stat() 

162 if info.st_uid != os.geteuid(): 

163 raise DirectoryNotPrivateError( 

164 f"{path} is not private: it is owned by uid {info.st_uid}, " 

165 f"but this process runs as uid {os.geteuid()}", 

166 OWNED_BY_ANOTHER_ACCOUNT, 

167 ) 

168 mode = stat.S_IMODE(info.st_mode) 

169 if mode != 0o700: 

170 raise DirectoryNotPrivateError( 

171 f"{path} is not private: its filesystem kept mode {mode:04o} " 

172 "after chmod 0700", 

173 MODE_NOT_KEPT, 

174 ) 

175 

176 

177def describe_private_directory_refusal(error: OSError) -> str: 

178 """Say why ``restrict_directory_to_owner`` refused a directory. 

179 

180 Fixed text for log lines: it never echoes the error message, only its 

181 category and, for other OS errors, the symbolic errno. 

182 """ 

183 reason = getattr(error, "reason", None) 

184 if reason == OWNED_BY_ANOTHER_ACCOUNT: 

185 return "another OS account owns it" 

186 if reason == MODE_NOT_KEPT: 

187 return "its filesystem did not keep mode 0700" 

188 code = errno.errorcode.get(getattr(error, "errno", None) or 0) 

189 return "it could not be created or set to mode 0700" + ( 

190 f" ({code})" if code else "" 

191 ) 

192 

193 

194def get_research_outputs_directory() -> Path: 

195 """Get the directory for research outputs and optional report backups. 

196 

197 A missing directory is created with mode 0700 (subject to the umask). 

198 An existing directory is returned unchanged: the web application 

199 restricts it at startup (``restrict_research_outputs_directory``), and 

200 ``FileReportStorage`` restricts and verifies it before writing. 

201 

202 Returns: 

203 Path to research outputs directory 

204 """ 

205 return _ensure_dir("research_outputs", label="research outputs", mode=0o700) 

206 

207 

208def restrict_research_outputs_directory() -> bool: 

209 """Restrict the research outputs directory to this OS account. 

210 

211 The web application calls this once at startup, so a directory left at 

212 0755 by an older release becomes 0700 even when no user has report file 

213 backups enabled. An ``OSError`` is logged with its remedy, not raised: 

214 ``FileReportStorage`` then refuses to write report backups there, and 

215 database report storage is unaffected. 

216 

217 Returns: 

218 False when the directory could not be made private, otherwise True 

219 (always True on non-POSIX systems, where nothing is changed). 

220 """ 

221 try: 

222 restrict_directory_to_owner(get_research_outputs_directory()) 

223 except OSError as exc: 

224 cause = describe_private_directory_refusal(exc) 

225 logger.warning( 

226 f"Could not make the research outputs directory private: {cause}. " 

227 "Report file backups will be refused until this is fixed; " 

228 f"reports are still saved in the database. {REPORT_BACKUP_REMEDY}." 

229 ) 

230 return False 

231 return True 

232 

233 

234def get_journal_data_directory() -> Path: 

235 """Get the directory for downloaded journal quality data files. 

236 

237 Contains openalex_sources.json.gz, doaj_journals.json, and the 

238 compiled journal_reference.db. Fetched on first use from 

239 OpenAlex and DOAJ APIs. 

240 

241 Returns: 

242 Path to journal data directory 

243 """ 

244 return _ensure_dir("journal_data") 

245 

246 

247def get_cache_directory() -> Path: 

248 """ 

249 Get the directory for storing cache files (search cache, etc.). 

250 

251 Returns: 

252 Path to cache directory 

253 """ 

254 return _ensure_dir("cache", label="cache") 

255 

256 

257def get_logs_directory() -> Path: 

258 """ 

259 Get the directory for storing log files. 

260 

261 Returns: 

262 Path to logs directory 

263 """ 

264 return _ensure_dir("logs", label="logs") 

265 

266 

267def get_encrypted_database_path() -> Path: 

268 """Get the path to the encrypted databases directory. 

269 

270 Returns: 

271 Path to the encrypted databases directory 

272 """ 

273 return _ensure_dir("encrypted_databases") 

274 

275 

276def get_user_database_filename(username: str) -> str: 

277 """Get the database filename for a specific user. 

278 

279 Args: 

280 username: The username to generate a filename for 

281 

282 Returns: 

283 The database filename (not full path) for the user 

284 

285 Raises: 

286 ValueError: If username is empty/None. Registration already enforces 

287 a non-empty username, but this is the actual hash chokepoint: 

288 sha256("") is a well-known constant, so silently accepting an 

289 empty username would let two different "no username" call sites 

290 collide into one shared, deterministic database filename instead 

291 of failing loudly. Defense-in-depth -- see #5481. 

292 """ 

293 if not username: 

294 raise ValueError( 

295 "Cannot compute a user database filename for an empty username" 

296 ) 

297 # Use username hash to avoid filesystem issues with special characters 

298 username_hash = hashlib.sha256(username.encode()).hexdigest()[:16] 

299 return f"ldr_user_{username_hash}.db" 

300 

301 

302def get_library_directory() -> Path: 

303 """ 

304 Get the directory for storing library files (documents, PDFs, etc.). 

305 

306 Returns: 

307 Path to library directory 

308 """ 

309 return _ensure_dir("library", label="library") 

310 

311 

312def get_config_directory() -> Path: 

313 """ 

314 Get the directory for storing configuration files. 

315 

316 Returns: 

317 Path to config directory 

318 """ 

319 return _ensure_dir("config", label="config") 

320 

321 

322def get_models_directory() -> Path: 

323 """ 

324 Get the directory for storing downloaded models. 

325 

326 Returns: 

327 Path to models directory 

328 """ 

329 return _ensure_dir("models", label="models") 

330 

331 

332def get_sentence_transformers_home() -> str | None: 

333 """ 

334 Get the sentence-transformers ecosystem cache override, if set. 

335 

336 Environment variable: 

337 SENTENCE_TRANSFORMERS_HOME: Third-party override (honored by the 

338 sentence-transformers library itself) for where model 

339 snapshots are stored. Read at call time so the live 

340 environment always wins, mirroring how the library 

341 resolves its own cache root. The raw value is returned 

342 (no Path coercion, empty string stays empty) so callers 

343 can preserve the library's precedence exactly. 

344 

345 Returns: 

346 The override path as a string, or None when unset (callers fall 

347 back to the HuggingFace Hub default cache). 

348 """ 

349 return os.getenv("SENTENCE_TRANSFORMERS_HOME") 

350 

351 

352def get_backup_directory() -> Path: 

353 """Get the base backup directory for all users.""" 

354 return _ensure_dir("encrypted_databases/backups") 

355 

356 

357def get_user_backup_directory(username: str) -> Path: 

358 """Get backup directory for a specific user. 

359 

360 Raises: 

361 ValueError: If username is empty/None. Same fail-closed guard as 

362 get_user_database_filename: sha256("") is a well-known constant, 

363 so silently accepting an empty username would collapse every 

364 "no username" call site into one shared, deterministic backup 

365 directory. Defense-in-depth -- see #5481. 

366 """ 

367 if not username: 

368 raise ValueError( 

369 "Cannot compute a user backup directory for an empty username" 

370 ) 

371 from local_deep_research.security.directory_creation import create_directory 

372 

373 username_hash = hashlib.sha256(username.encode()).hexdigest()[:16] 

374 backup_directory = get_backup_directory() 

375 user_backup_dir = create_directory( 

376 backup_directory / username_hash, 

377 context="user backup directory", 

378 root=backup_directory, 

379 mode=0o700, 

380 ) 

381 # Enforce 0o700 regardless of umask (mkdir mode is umask-masked) 

382 user_backup_dir.chmod(0o700) 

383 return user_backup_dir 

384 

385 

386# Convenience functions for backward compatibility 

387def get_data_dir() -> str: 

388 """Get data directory as string for backward compatibility.""" 

389 return str(get_data_directory())