Coverage for src/local_deep_research/utilities/office_conversion.py: 90%

80 statements  

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

1"""Bounded conversion of legacy office formats (.doc/.ppt). 

2 

3``.doc``/``.ppt`` uploads are OLE binaries; the loader path converts 

4them to modern containers via a ``soffice`` subprocess before parsing. 

5The upstream conversion (unstructured's ``convert_office_doc``) invokes 

6``soffice`` with **no timeout**, so a hostile or pathological file can 

7pin an indexing worker forever — for content up to the 3 GB upload cap. 

8 

9This module owns the conversion instead: only input carrying the OLE 

10compound-file signature is handed to soffice, a real wall-clock timeout 

11enforced with a process-group kill (``soffice`` spawns children that a 

12bare ``subprocess.run(timeout=...)`` would leave alive), typed 

13failures, output validation (expected file, size ceiling), soffice's 

14stdio sent to ``/dev/null`` rather than buffered, a per-call 

15``UserInstallation`` profile so concurrent conversions cannot collide 

16on soffice's singleton profile lock, and full temp cleanup in 

17``finally``. soffice's memory is not capped (it is bounded by the 

18timeout only); the OLE-signature check keeps renamed zip containers 

19and other formats from reaching it. 

20""" 

21 

22from __future__ import annotations 

23 

24import contextlib 

25import os 

26import shutil 

27import signal 

28import subprocess 

29import tempfile 

30import uuid 

31from pathlib import Path 

32from typing import Optional 

33 

34from loguru import logger 

35 

36#: Legacy extensions -> the modern container soffice produces. 

37_LEGACY_TARGETS = {".doc": ".docx", ".ppt": ".pptx"} 

38 

39#: OLE compound-file signature: every genuine .doc/.ppt starts with it. 

40OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" 

41 

42#: soffice binary candidates, resolved once per call via shutil.which. 

43_SOFFICE_CANDIDATES = ("soffice", "libreoffice") 

44 

45#: Conversion wall clock. Generous for real documents (a 3 GB file is 

46#: rejected by the upload cap long before this; honest conversions of 

47#: large-but-legitimate documents finish in tens of seconds) while 

48#: keeping a hung process tree from pinning a worker forever. 

49DEFAULT_CONVERSION_TIMEOUT_S = 120 

50 

51#: Converted-output ceiling. A conversion that legitimately needs more 

52#: than this was never going to be indexed usefully. 

53DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024 * 1024 

54 

55 

56class OfficeConversionError(ValueError): 

57 """A legacy-office conversion failed, timed out, or was refused.""" 

58 

59 

60def _normalized_ext(extension: str) -> str: 

61 return ( 

62 extension.lower() 

63 if extension.startswith(".") 

64 else f".{extension.lower()}" 

65 ) 

66 

67 

68def _kill_process_group(pid: int) -> None: 

69 """Best-effort SIGKILL of *pid*'s whole process group. 

70 

71 On platforms without process groups (Windows) this falls back to 

72 terminating *pid* alone: soffice's helper processes survive there. 

73 """ 

74 with contextlib.suppress(ProcessLookupError, PermissionError): 

75 if hasattr(os, "killpg"): 

76 os.killpg(pid, signal.SIGKILL) 

77 else: # pragma: no cover - Windows has no process groups 

78 os.kill(pid, signal.SIGTERM) 

79 

80 

81def convert_legacy_office_bytes( 

82 content: bytes, 

83 extension: str, 

84 *, 

85 output_dir: Optional[Path] = None, 

86 timeout_s: int = DEFAULT_CONVERSION_TIMEOUT_S, 

87 max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES, 

88 input_stem: Optional[str] = None, 

89) -> bytes: 

90 """Convert legacy OLE office bytes to their modern container. 

91 

92 Returns the converted container's bytes. Raises 

93 ``OfficeConversionError`` (a ``ValueError``) on timeout, non-zero 

94 exit, missing/unexpected output, or an output over 

95 ``max_output_bytes``. All temporary files are removed whether the 

96 conversion succeeds or fails. 

97 """ 

98 ext = _normalized_ext(extension) 

99 target = _LEGACY_TARGETS.get(ext) 

100 if target is None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 raise OfficeConversionError(f"not a legacy office extension: {ext}") 

102 

103 # soffice sniffs content, not the extension: anything else renamed 

104 # .doc/.ppt (a zip bomb, an RTF, an HTML page) would reach a filter 

105 # outside every other guard, with no memory cap on soffice. Only 

106 # the OLE compound files these extensions denote are converted. 

107 if not content.startswith(OLE_MAGIC): 

108 raise OfficeConversionError(f"{ext} upload is not an OLE compound file") 

109 

110 soffice = next( 

111 (name for name in _SOFFICE_CANDIDATES if shutil.which(name)), None 

112 ) 

113 if soffice is None: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 raise OfficeConversionError("soffice binary not available") 

115 

116 # mkdtemp is absolute by construction; a caller-supplied output_dir 

117 # may be relative, which would break profile_dir.as_uri() below. 

118 work_dir = ( 

119 # nosemgrep: semgrep.rules.path-traversal-risk, reason: mkdtemp creates a private server-owned directory 

120 Path(tempfile.mkdtemp(prefix="ldr-office-")) 

121 if output_dir is None 

122 # nosemgrep: semgrep.rules.path-traversal-risk, reason: upload caller omits this optional trusted-code path 

123 else Path(output_dir).resolve() 

124 ) 

125 

126 if input_stem is None: 

127 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: validated local filename stem; no SQL sink 

128 stem = f"in_{uuid.uuid4().hex}" 

129 elif input_stem and all(c.isalnum() or c in "-_" for c in input_stem): 

130 stem = input_stem 

131 else: 

132 # A traversal-shaped stem must never escape the work dir. 

133 raise OfficeConversionError("invalid conversion input stem") 

134 

135 in_path = work_dir / f"{stem}{ext}" 

136 out_path = work_dir / f"{stem}{target}" 

137 # Per-call profile: soffice serializes on a shared UserInstallation 

138 # and concurrent conversions would block or corrupt each other. 

139 profile_dir = work_dir / f"profile_{uuid.uuid4().hex}" 

140 

141 try: 

142 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Validated stem and allowlisted extension inside a private conversion directory; upload callers cannot choose output_dir 

143 in_path.write_bytes(content) 

144 # Defensive pre-clean: a stale file at out_path from an earlier 

145 # call in a shared output_dir must not fake a successful run. 

146 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Fixed converted filename derived from the validated stem and allowlisted target extension in the conversion directory 

147 out_path.unlink(missing_ok=True) 

148 

149 command = [ 

150 soffice, 

151 "--headless", 

152 "--norestore", 

153 "--nolockcheck", 

154 f"-env:UserInstallation={profile_dir.as_uri()}", 

155 "--convert-to", 

156 target.lstrip("."), 

157 "--outdir", 

158 str(work_dir), 

159 str(in_path), 

160 ] 

161 try: 

162 proc = subprocess.Popen( # noqa: S603 fixed argv, no shell 

163 command, 

164 # Nothing is read from soffice's streams: /dev/null 

165 # keeps a chatty or hostile conversion from growing 

166 # an in-memory pipe buffer in the worker. 

167 stdin=subprocess.DEVNULL, 

168 stdout=subprocess.DEVNULL, 

169 stderr=subprocess.DEVNULL, 

170 start_new_session=True, 

171 ) 

172 try: 

173 proc.communicate(timeout=timeout_s) 

174 except subprocess.TimeoutExpired: 

175 # start_new_session makes proc.pid the process-group 

176 # id: kill the whole tree, then reap, then fail typed. 

177 _kill_process_group(proc.pid) 

178 proc.wait() 

179 raise OfficeConversionError( 

180 f"legacy office conversion timed out after {timeout_s}s" 

181 ) from None 

182 except OfficeConversionError: 

183 raise 

184 except OSError as exc: 

185 raise OfficeConversionError( 

186 f"could not launch soffice ({type(exc).__name__})" 

187 ) from exc 

188 

189 if proc.returncode != 0: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 raise OfficeConversionError( 

191 f"legacy office conversion failed (exit {proc.returncode})" 

192 ) 

193 

194 if not out_path.exists(): 

195 raise OfficeConversionError( 

196 "legacy office conversion produced no output" 

197 ) 

198 

199 output_size = out_path.stat().st_size 

200 if output_size > max_output_bytes: 

201 raise OfficeConversionError( 

202 f"converted output is larger than {max_output_bytes} bytes" 

203 ) 

204 

205 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Read the size-checked converter output with the fixed target extension from this call's conversion directory 

206 converted = out_path.read_bytes() 

207 logger.debug( 

208 "legacy office conversion ok: {} -> {} ({} bytes)", 

209 ext, 

210 target, 

211 output_size, 

212 ) 

213 return converted 

214 finally: 

215 # Full temp hygiene on every path, success or failure: the 

216 # caller's content, soffice's output, and the profile tree. 

217 for path in (in_path, out_path): 

218 try: 

219 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Cleanup is limited to this conversion's validated input and output filenames 

220 path.unlink(missing_ok=True) 

221 except OSError: 

222 logger.warning("could not remove conversion temp file") 

223 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Profile directory uses a fresh UUID beneath this call's conversion directory 

224 shutil.rmtree(profile_dir, ignore_errors=True) 

225 if output_dir is None: 

226 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Only this call's mkdtemp directory is recursively removed; caller-owned output directories are retained 

227 shutil.rmtree(work_dir, ignore_errors=True) 

228 

229 

230def is_legacy_office_extension(extension: str) -> bool: 

231 """True when *extension* is one of the OLE formats needing conversion.""" 

232 return _normalized_ext(extension) in _LEGACY_TARGETS 

233 

234 

235def converted_extension(extension: str) -> str: 

236 """The modern container extension a legacy *extension* converts to.""" 

237 return _LEGACY_TARGETS[_normalized_ext(extension)]