#5142: office-doc preview + safe docx editing (#540), rebased on master; optional deps

This commit is contained in:
nesquena-hermes 2026-07-02 01:15:57 +00:00
parent 747e42e788
commit 76d6cc7f74
12 changed files with 1715 additions and 13 deletions

View file

@ -102,7 +102,8 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard
# Office parsers stay optional at runtime; CI installs them explicitly for the workspace preview tests.
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard python-docx openpyxl python-pptx
# ruff is installed so tests/test_ruff_forward_lint.py runs its E9/F821
# tree-clean assertions in-suite (mirrors how eslint is available for
# tests/test_static_js_runtime_lint.py). If install fails the test

View file

@ -23,6 +23,10 @@
- **No more 57–70 s cold-startup stalls from the profile skills-stats thundering herd.** At container boot the frontend fires several profile-data requests at once; with `ThreadingHTTPServer` (one thread per request) they all missed the empty skills-stats cache simultaneously and each walked + parsed every profile's skill tree, stacking thousands of concurrent `stat()` calls under Docker's overlay2 filesystem. `_get_profile_skills_stats()` now serializes per-profile with double-checked locking (concurrent misses on one profile collapse to a single compute; independent profiles still compute in parallel), and `list_profiles_api()` single-flights the row build under `_LIST_PROFILES_CACHE_LOCK` so one thread builds while the rest wait for the cached result. The every-call cheap mtime probe (the #4783 out-of-band change-detection contract) is unchanged. (#5364)
### Added
- **The workspace file viewer now previews modern Office documents (`.docx` / `.xlsx` / `.pptx`) and lets you edit plain, structurally-simple `.docx` files in place.** A shared backend Office authority owns previewability, editability, and archive-safety: parsers are optional at runtime (lazy-imported; a lean install degrades to a clean 503 install hint, not a crash), every OOXML archive is preflighted against decompression-bomb limits on *actual inflated bytes* before any parser runs, and preview size is bounded during accumulation. `.docx` editing is fail-closed — only documents whose body is plain paragraphs (no custom sections/tables/headers/rich runs) are editable, save rebuilds from the original package so document metadata/styles/theme are preserved, and the saved bytes are re-verified before write. Previews render as escaped text (no HTML injection). Thanks @rodboev. (#5142, part of #540)
## [v0.51.792] — 2026-07-01
### Fixed

647
api/office_documents.py Normal file
View file

@ -0,0 +1,647 @@
from __future__ import annotations
import io
import posixpath
import zipfile
import zlib
from pathlib import Path
CLAIMED_OFFICE_EXTENSIONS = frozenset({".docx", ".xlsx", ".pptx"})
CLAIMED_OFFICE_FORMATS = frozenset({"docx", "xlsx", "pptx"})
OFFICE_PREVIEW_KIND = "office"
OFFICE_RENDER_MODE = "code"
OFFICE_DEPENDENCY_HINT = (
"Office preview is not available on this server. Install python-docx, "
"openpyxl, and python-pptx to enable it: pip install python-docx openpyxl "
"python-pptx"
)
OFFICE_PREVIEW_TRUNCATED_NOTICE = "[Preview truncated: Office content exceeds safe limits]"
MAX_OFFICE_PREVIEW_CHARS = 120_000
MAX_DOCX_PREVIEW_BLOCKS = 2_000
MAX_DOCX_TABLE_CELLS = 5_000
MAX_XLSX_PREVIEW_SHEETS = 20
MAX_XLSX_PREVIEW_ROWS_PER_SHEET = 500
MAX_XLSX_PREVIEW_CELLS_PER_SHEET = 5_000
MAX_PPTX_PREVIEW_SLIDES = 100
MAX_PPTX_PREVIEW_SHAPES_PER_SLIDE = 200
MAX_OFFICE_ARCHIVE_MEMBERS = 256
MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES = 8_000_000
MAX_OFFICE_ARCHIVE_MEMBER_BYTES = 4_000_000
MAX_OFFICE_ARCHIVE_MAX_COMPRESSION_RATIO = 200
MAX_DOCX_ARCHIVE_DOCUMENT_BYTES = 4_000_000
MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES = 4_000_000
MAX_XLSX_ARCHIVE_WORKSHEET_BYTES = 2_000_000
MAX_XLSX_ARCHIVE_METADATA_BYTES = 512_000
MAX_PPTX_ARCHIVE_SLIDE_BYTES = 1_000_000
MAX_PPTX_ARCHIVE_MEDIA_BYTES = 2_000_000
_WORD_NAMESPACE = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
_DEFAULT_DOCX_SECTION_SIGNATURE = None
_DOCX_BODY_CHILDREN = {f"{_WORD_NAMESPACE}p", f"{_WORD_NAMESPACE}sectPr"}
_DOCX_PARAGRAPH_CHILDREN = {f"{_WORD_NAMESPACE}pPr", f"{_WORD_NAMESPACE}r"}
_DOCX_SAFE_PARAGRAPH_PROPERTY_CHILDREN = {f"{_WORD_NAMESPACE}pStyle"}
_DOCX_RUN_CHILDREN = {f"{_WORD_NAMESPACE}t"}
def _office_dependency_import_error() -> ImportError:
return ImportError(OFFICE_DEPENDENCY_HINT)
def _office_archive_limit_error() -> ValueError:
return ValueError("Office preview exceeds safe archive limits")
def _office_preview_read_error(office_format: str) -> ValueError:
return ValueError(f"Unable to read {office_format.upper()} preview")
def _load_docx_document():
try:
from docx import Document as document_factory
except ImportError as exc: # pragma: no cover - depends on local install shape
raise _office_dependency_import_error() from exc
return document_factory
def _load_workbook_reader():
try:
from openpyxl import load_workbook as workbook_reader
except ImportError as exc: # pragma: no cover - depends on local install shape
raise _office_dependency_import_error() from exc
return workbook_reader
def _load_presentation_ctor():
try:
from pptx import Presentation as presentation_ctor
except ImportError as exc: # pragma: no cover - depends on local install shape
raise _office_dependency_import_error() from exc
return presentation_ctor
def _normalise_archive_member_name(name: str) -> str:
normalized = posixpath.normpath(name.replace("\\", "/"))
if name.startswith(("/", "\\")) or normalized in {"", ".", ".."} or normalized.startswith("../"):
raise _office_archive_limit_error()
return normalized
def _archive_member_byte_limit(office_format: str, member_name: str) -> int:
if office_format == "docx":
if member_name == "word/document.xml":
return MAX_DOCX_ARCHIVE_DOCUMENT_BYTES
elif office_format == "xlsx":
if member_name == "xl/sharedStrings.xml":
return MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES
if member_name.startswith("xl/worksheets/"):
return MAX_XLSX_ARCHIVE_WORKSHEET_BYTES
if member_name.startswith("xl/theme/") or member_name.startswith("docProps/") or member_name == "xl/workbook.xml":
return MAX_XLSX_ARCHIVE_METADATA_BYTES
elif office_format == "pptx":
if member_name.startswith("ppt/slides/"):
return MAX_PPTX_ARCHIVE_SLIDE_BYTES
if member_name.startswith("ppt/media/"):
return MAX_PPTX_ARCHIVE_MEDIA_BYTES
return MAX_OFFICE_ARCHIVE_MEMBER_BYTES
def _preflight_office_archive(office_format: str, raw: bytes) -> None:
try:
archive = zipfile.ZipFile(io.BytesIO(raw))
except zipfile.BadZipFile as exc:
raise _office_preview_read_error(office_format) from exc
with archive:
file_infos = [info for info in archive.infolist() if not info.is_dir()]
if len(file_infos) > MAX_OFFICE_ARCHIVE_MEMBERS:
raise _office_archive_limit_error()
total_uncompressed = 0
for info in file_infos:
member_name = _normalise_archive_member_name(info.filename)
member_limit = _archive_member_byte_limit(office_format, member_name)
member_size = 0
try:
member = archive.open(info)
except Exception as exc: # pragma: no cover - malformed archive path
raise _office_preview_read_error(office_format) from exc
with member:
try:
while True:
chunk = member.read(64 * 1024)
if not chunk:
break
member_size += len(chunk)
total_uncompressed += len(chunk)
if member_size > member_limit or total_uncompressed > MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES:
raise _office_archive_limit_error()
except (zipfile.BadZipFile, zlib.error, EOFError, OSError) as exc:
# Corrupt/truncated member data (bad CRC, short read, etc.) —
# mundane for partially-uploaded files, not adversarial. Raise
# the module's intended read-error ValueError (handled as a
# clean 4xx) instead of letting BadZipFile/zlib.error escape to
# the route's catch-all as an unhandled 500 + traceback. The
# limit-exceeded ValueError above is deliberately NOT caught
# here (it is not one of these decompression exception types).
raise _office_preview_read_error(office_format) from exc
if member_size > max(info.compress_size, 1) * MAX_OFFICE_ARCHIVE_MAX_COMPRESSION_RATIO:
raise _office_archive_limit_error()
def is_claimed_office_path(path: str | Path) -> bool:
return Path(str(path)).suffix.lower() in CLAIMED_OFFICE_EXTENSIONS
def _office_format_for_path(path: str | Path) -> str:
return Path(str(path)).suffix.lower().lstrip(".")
def _normalise_preview_text(value, max_chars: int | None = None) -> str:
if value is None:
return ""
text = str(value)
if max_chars is not None and max_chars >= 0 and len(text) > max_chars:
text = text[:max_chars]
return text.replace("\r", "\n").replace("\n", " ").strip()
def _preview_line_count(content: str) -> int:
if not content:
return 1
return content.count("\n") + 1
def _finalize_preview_text(content: str, truncated: bool = False, strip_edges: bool = True) -> tuple[str, bool]:
# docx passes strip_edges=False: leading/trailing blank paragraphs are
# meaningful body content and the editor textarea is prefilled from this
# text, so stripping edge whitespace would silently drop those paragraphs on
# an unedited save (interior blanks already round-trip). xlsx/pptx keep the
# strip — their previews are read-only and edge whitespace is just noise.
text = (content or "").strip() if strip_edges else (content or "")
if len(text) > MAX_OFFICE_PREVIEW_CHARS:
text = text[:MAX_OFFICE_PREVIEW_CHARS].rstrip()
truncated = True
if truncated:
text = f"{text}\n\n{OFFICE_PREVIEW_TRUNCATED_NOTICE}" if text else OFFICE_PREVIEW_TRUNCATED_NOTICE
return text, truncated
class _PreviewBuilder:
def __init__(self, char_limit: int | None = None) -> None:
self._char_limit = MAX_OFFICE_PREVIEW_CHARS if char_limit is None else max(char_limit, 0)
self._parts: list[str] = []
self._length = 0
self._started = False
self.truncated = False
@property
def remaining_chars(self) -> int:
return max(self._char_limit - self._length, 0)
@property
def started(self) -> bool:
return self._started
@property
def has_content(self) -> bool:
return bool(self._parts)
@property
def text(self) -> str:
return "".join(self._parts)
def _append_piece(self, piece: str) -> bool:
if self.truncated:
return False
if not piece:
return True
remaining = self.remaining_chars
if remaining <= 0:
self.truncated = True
return False
if len(piece) > remaining:
piece = piece[:remaining].rstrip()
self.truncated = True
if piece:
self._parts.append(piece)
self._length += len(piece)
return not self.truncated
def start_line(self) -> bool:
if self._started:
return self._append_piece("\n")
self._started = True
return True
def start_section(self) -> bool:
if self._started:
return self._append_piece("\n\n")
self._started = True
return True
def append_text(self, text: str) -> bool:
if not self._started:
self._started = True
return self._append_piece(text)
def finish(self, strip_edges: bool = True) -> tuple[str, bool]:
return _finalize_preview_text(self.text, self.truncated, strip_edges=strip_edges)
def _append_normalized_preview_text(builder: _PreviewBuilder, value) -> bool:
if builder.truncated:
return False
remaining = builder.remaining_chars
if remaining <= 0:
builder.truncated = True
return False
raw_text = "" if value is None else str(value)
clipped = len(raw_text) > remaining
if not builder.append_text(_normalise_preview_text(raw_text, remaining)):
return False
if clipped:
builder.truncated = True
return False
return True
def _append_verbatim_preview_text(builder: _PreviewBuilder, value) -> bool:
"""Append run text WITHOUT per-node strip/space-normalization (docx runs).
A docx paragraph is split across multiple ``<w:t>`` runs and the whitespace
at run boundaries is significant: ``p.add_run("Hello ") + p.add_run("world")``
stores two runs whose text is ``"Hello "`` and ``"world"``. The general
``_normalise_preview_text`` path ``.strip()``s each node and concatenates
with no separator, which corrupts that to ``"Helloworld"`` — and because the
editor textarea is prefilled from the preview text, opening + saving such a
file (even with no edits) persisted the corruption to disk. That
normalization is still correct for xlsx cells / pptx shapes (whole-cell
values, not run fragments), so only the docx run path switches to verbatim
append. Text is budget-clipped but never stripped/space-collapsed.
"""
if builder.truncated:
return False
remaining = builder.remaining_chars
if remaining <= 0:
builder.truncated = True
return False
raw_text = "" if value is None else str(value)
clipped = len(raw_text) > remaining
if not builder.append_text(raw_text):
return False
if clipped:
builder.truncated = True
return False
return True
def _iter_docx_text_nodes(element):
for node in element.iter():
if node.tag == f"{_WORD_NAMESPACE}t" and node.text:
yield node.text
def _append_docx_element_text(builder: _PreviewBuilder, element) -> bool:
for text in _iter_docx_text_nodes(element):
# Verbatim (not _normalise_preview_text): preserve run-boundary
# whitespace so multi-run paragraphs round-trip losslessly. See
# _append_verbatim_preview_text for why docx differs from xlsx/pptx.
if not _append_verbatim_preview_text(builder, text):
return False
return True
def _append_docx_cell_text(builder: _PreviewBuilder, cell_element) -> bool:
first_paragraph = True
for child in cell_element:
if child.tag != f"{_WORD_NAMESPACE}p":
continue
if not first_paragraph and not builder.append_text("\n"):
return False
if not _append_docx_element_text(builder, child):
return False
first_paragraph = False
return True
def _docx_preview_text(document) -> tuple[str, bool]:
builder = _PreviewBuilder()
body_blocks_seen = 0
table_cells_seen = 0
table_index = 0
for child in document._element.body:
if child.tag == f"{_WORD_NAMESPACE}sectPr":
continue
body_blocks_seen += 1
if body_blocks_seen > MAX_DOCX_PREVIEW_BLOCKS:
builder.truncated = True
break
if child.tag == f"{_WORD_NAMESPACE}p":
if not builder.start_line() or not _append_docx_element_text(builder, child):
break
continue
if child.tag != f"{_WORD_NAMESPACE}tbl":
continue
table_index += 1
if not builder.start_line() or not builder.append_text(f"Table {table_index}"):
break
for row in child:
if row.tag != f"{_WORD_NAMESPACE}tr":
continue
if not builder.start_line():
break
first_cell = True
for cell in row:
if cell.tag != f"{_WORD_NAMESPACE}tc":
continue
table_cells_seen += 1
if table_cells_seen > MAX_DOCX_TABLE_CELLS:
builder.truncated = True
break
if not first_cell and not builder.append_text("\t"):
break
if not _append_docx_cell_text(builder, cell):
break
first_cell = False
if builder.truncated:
break
if builder.truncated:
break
# strip_edges=False: preserve leading/trailing blank paragraphs so an
# unedited open->save round-trips them (the editor prefills from this text).
return builder.finish(strip_edges=False)
def _docx_paragraph_properties_are_safe(properties) -> bool:
for child in properties:
if child.tag not in _DOCX_SAFE_PARAGRAPH_PROPERTY_CHILDREN:
return False
if child.tag == f"{_WORD_NAMESPACE}pStyle" and child.get(f"{_WORD_NAMESPACE}val") != "Normal":
return False
return True
def _docx_xml_signature(element) -> tuple:
attributes = tuple(
sorted(
(key, value)
for key, value in element.attrib.items()
if not key.rsplit("}", 1)[-1].startswith("rsid")
)
)
children = tuple(_docx_xml_signature(child) for child in element)
text = (element.text or "").strip()
return element.tag, attributes, text, children
def _default_docx_section_signature() -> tuple:
global _DEFAULT_DOCX_SECTION_SIGNATURE
if _DEFAULT_DOCX_SECTION_SIGNATURE is None:
document = _load_docx_document()()
_DEFAULT_DOCX_SECTION_SIGNATURE = tuple(
_docx_xml_signature(child) for child in document._element.body.sectPr
)
return _DEFAULT_DOCX_SECTION_SIGNATURE
def _docx_section_properties_are_safe(section_properties) -> bool:
return tuple(_docx_xml_signature(child) for child in section_properties) == _default_docx_section_signature()
def _docx_editability(document) -> tuple[bool, str | None]:
body = document._element.body
for child in body:
if child.tag not in _DOCX_BODY_CHILDREN:
return False, "docx contains unsupported structures"
if child.tag == f"{_WORD_NAMESPACE}sectPr" and not _docx_section_properties_are_safe(child):
return False, "docx contains unsupported section content"
for paragraph in document.paragraphs:
for child in paragraph._p:
if child.tag not in _DOCX_PARAGRAPH_CHILDREN:
return False, "docx contains unsupported paragraph structures"
if child.tag == f"{_WORD_NAMESPACE}pPr" and not _docx_paragraph_properties_are_safe(child):
return False, "docx contains unsupported paragraph structures"
for run in paragraph.runs:
for child in run._r:
if child.tag not in _DOCX_RUN_CHILDREN:
return False, "docx contains unsupported inline content"
return True, None
def _preview_docx(raw: bytes) -> tuple[str, bool, str | None, bool]:
_preflight_office_archive("docx", raw)
try:
document = _load_docx_document()(io.BytesIO(raw))
except ImportError:
raise
except Exception as exc: # pragma: no cover - library-specific failure mode
raise ValueError("Unable to read DOCX preview") from exc
content, truncated = _docx_preview_text(document)
if truncated:
return content, False, "docx preview exceeds safe limits", True
editable, reason = _docx_editability(document)
return content, editable, reason, truncated
def _preview_xlsx(raw: bytes) -> tuple[str, bool]:
_preflight_office_archive("xlsx", raw)
try:
workbook = _load_workbook_reader()(io.BytesIO(raw), data_only=True, read_only=True)
except ImportError:
raise
except Exception as exc: # pragma: no cover - library-specific failure mode
raise ValueError("Unable to read XLSX preview") from exc
builder = _PreviewBuilder()
try:
for sheet_index, sheet in enumerate(workbook.worksheets, start=1):
if sheet_index > MAX_XLSX_PREVIEW_SHEETS:
builder.truncated = True
break
if not builder.start_section() or not builder.append_text(f"Sheet: {sheet.title}"):
break
rows_seen = 0
cells_seen = 0
# openpyxl read-only mode reports max_row/max_column as None for any
# workbook lacking a <dimension> record — which includes everything
# openpyxl.Workbook(write_only=True) produces. `getattr(..., DEFAULT)`
# does NOT help (the attribute exists; its value is None).
#
# When the dimension IS known, bound iter_rows to the capped extent.
# When it's UNKNOWN, pass None so openpyxl yields each row's NATURAL
# width — passing the cap instead would make openpyxl pad every row
# out to `max_col` with None cells, exhausting the per-sheet cell
# budget on row 1 and dropping the rest of the sheet. The rows_seen /
# cells_seen counters below bound the work in the unknown case.
sheet_max_row = getattr(sheet, "max_row", None)
sheet_max_col = getattr(sheet, "max_column", None)
max_row = min(sheet_max_row, MAX_XLSX_PREVIEW_ROWS_PER_SHEET) if sheet_max_row else None
max_col = min(sheet_max_col, MAX_XLSX_PREVIEW_CELLS_PER_SHEET) if sheet_max_col else None
for row in sheet.iter_rows(values_only=True, max_row=max_row, max_col=max_col):
rows_seen += 1
if rows_seen > MAX_XLSX_PREVIEW_ROWS_PER_SHEET:
builder.truncated = True
break
row_budget = builder.remaining_chars - (1 if builder.started else 0)
row_builder = _PreviewBuilder(row_budget)
for value in row:
cells_seen += 1
if cells_seen > MAX_XLSX_PREVIEW_CELLS_PER_SHEET:
builder.truncated = True
break
if row_builder.has_content and not row_builder.append_text("\t"):
break
if not _append_normalized_preview_text(row_builder, value):
if not row_builder.truncated:
builder.truncated = True
break
if builder.truncated:
break
if row_builder.has_content:
if not builder.start_line() or not builder.append_text(row_builder.text):
break
if row_builder.truncated:
builder.truncated = True
break
if builder.truncated:
break
if (getattr(sheet, "max_row", None) or rows_seen) > MAX_XLSX_PREVIEW_ROWS_PER_SHEET:
builder.truncated = True
break
if (getattr(sheet, "max_column", None) or 0) > MAX_XLSX_PREVIEW_CELLS_PER_SHEET:
builder.truncated = True
break
finally:
close = getattr(workbook, "close", None)
if callable(close):
close()
if not builder.has_content:
return _finalize_preview_text("Empty workbook", builder.truncated)
return builder.finish()
def _preview_pptx(raw: bytes) -> tuple[str, bool]:
_preflight_office_archive("pptx", raw)
try:
presentation = _load_presentation_ctor()(io.BytesIO(raw))
except ImportError:
raise
except Exception as exc: # pragma: no cover - library-specific failure mode
raise ValueError("Unable to read PPTX preview") from exc
builder = _PreviewBuilder()
for slide_index, slide in enumerate(presentation.slides, start=1):
if slide_index > MAX_PPTX_PREVIEW_SLIDES:
builder.truncated = True
break
if not builder.start_section() or not builder.append_text(f"Slide {slide_index}"):
break
shapes_seen = 0
has_text = False
for shape in slide.shapes:
shapes_seen += 1
if shapes_seen > MAX_PPTX_PREVIEW_SHAPES_PER_SLIDE:
builder.truncated = True
break
text = getattr(shape, "text", "")
if not _normalise_preview_text(text):
continue
if not builder.start_line() or not _append_normalized_preview_text(builder, text):
break
has_text = True
if builder.truncated:
break
if not has_text and (not builder.start_line() or not builder.append_text("(empty slide)")):
break
if not builder.has_content:
return _finalize_preview_text("Empty presentation", builder.truncated)
return builder.finish()
def preview_office_document(path: str | Path, raw: bytes) -> dict:
office_format = _office_format_for_path(path)
if office_format not in CLAIMED_OFFICE_FORMATS:
raise ValueError(f"Unsupported Office format: {path}")
truncated = False
if office_format == "docx":
content, editable, reason, truncated = _preview_docx(raw)
elif office_format == "xlsx":
content, truncated = _preview_xlsx(raw)
editable, reason = False, "xlsx preview is read-only in this slice"
elif office_format == "pptx":
content, truncated = _preview_pptx(raw)
editable, reason = False, "pptx preview is read-only in this slice"
else: # pragma: no cover - exhaustive guard
raise ValueError(f"Unsupported Office format: {path}")
payload = {
"path": str(path),
"content": content,
"size": len(raw),
"lines": _preview_line_count(content),
"preview_kind": OFFICE_PREVIEW_KIND,
"office_format": office_format,
"render_mode": OFFICE_RENDER_MODE,
"editable": editable,
}
if reason:
payload["edit_blocked_reason"] = reason
if truncated:
payload["truncated"] = True
return payload
def _docx_bytes_from_text(content: str, current_bytes: bytes | None = None) -> bytes:
"""Rebuild a docx from edited plain text, preserving the original package.
We reload the CURRENT document (styles.xml, docProps/core.xml, theme,
settings, sectPr) and replace only the body's paragraph content, rather than
starting from python-docx's blank template. Building from the blank template
silently wiped author/title/custom styles on an unedited open→save — the
same fail-closed round-trip class the sectPr and run-whitespace fixes closed.
When current_bytes is unavailable we fall back to a fresh document.
Content is bounded BEFORE the (quadratic — python-docx scans for sectPr on
every add_paragraph) build loop: an editable preview is capped at
MAX_OFFICE_PREVIEW_CHARS and MAX_DOCX_PREVIEW_BLOCKS lines, so any legitimate
editor save fits well within these bounds. Rejecting oversized input up front
prevents a write-surface CPU/RSS DoS (measured ~20s CPU for 50k lines).
"""
text = str(content or "").replace("\r\n", "\n").replace("\r", "\n")
if len(text) > MAX_OFFICE_PREVIEW_CHARS:
raise ValueError("DOCX content exceeds the editable size limit")
lines = text.split("\n")
if len(lines) > MAX_DOCX_PREVIEW_BLOCKS:
raise ValueError("DOCX content exceeds the editable paragraph limit")
if current_bytes is not None:
try:
document = _load_docx_document()(io.BytesIO(current_bytes))
except ImportError:
raise
except Exception:
document = _load_docx_document()()
else:
document = _load_docx_document()()
body = document._element.body
for child in list(body):
if child.tag != f"{_WORD_NAMESPACE}sectPr":
body.remove(child)
for line in lines:
document.add_paragraph(line)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def save_office_document(path: str | Path, current_bytes: bytes, content: str) -> tuple[dict, bytes]:
office_format = _office_format_for_path(path)
if office_format != "docx":
raise ValueError(f"{office_format or 'office file'} is preview-only in this slice")
current_preview = preview_office_document(path, current_bytes)
if not current_preview.get("editable"):
raise ValueError(current_preview.get("edit_blocked_reason") or "DOCX document is not editable")
# Rebuild from the CURRENT package so styles/docProps/theme survive an
# unedited round-trip; the body is still fully replaced and re-verified.
saved_bytes = _docx_bytes_from_text(content, current_bytes)
saved_preview = preview_office_document(path, saved_bytes)
if not saved_preview.get("editable"):
raise ValueError(saved_preview.get("edit_blocked_reason") or "Saved DOCX is not editable")
return saved_preview, saved_bytes

View file

@ -13940,6 +13940,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/file/save":
return _handle_file_save(handler, body)
if parsed.path == "/api/file/office-save":
return _handle_office_file_save(handler, body)
if parsed.path == "/api/file/create":
return _handle_file_create(handler, body)
@ -15470,6 +15473,10 @@ def _handle_escape_file_read(handler, parsed):
return bad(handler, _sanitize_error(exc), 404)
except EscapeAuthorizationExpiredError as exc:
return bad(handler, _sanitize_error(exc), 403)
except ImportError as exc:
# Optional Office parsers absent on a lean install — mirror
# _handle_file_read: a 503 with the install hint, not a 500 traceback.
return bad(handler, _sanitize_error(exc), 503)
except ValueError as exc:
return bad(handler, _sanitize_error(exc), 404)
@ -17331,10 +17338,23 @@ def _handle_file_read(handler, parsed):
return bad(handler, "path is required")
try:
return j(handler, read_file_content(Path(s.workspace), rel))
except ImportError as e:
return bad(handler, str(e), 503)
except (FileNotFoundError, ValueError) as e:
return bad(handler, _sanitize_error(e), 404)
def _read_anchored_file_bytes(ws_root: Path, target: Path) -> bytes:
fd = open_anchored_fd(ws_root, target, want_dir=False)
with os.fdopen(fd, "rb", closefd=True) as fh:
st = os.fstat(fh.fileno())
if not _stat.S_ISREG(st.st_mode):
raise FileNotFoundError(f"Not a file: {target}")
if st.st_size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
return fh.read(MAX_FILE_BYTES + 1)
def _handle_approval_pending(handler, parsed):
sid = parse_qs(parsed.query).get("session_id", [""])[0]
with _lock:
@ -20800,6 +20820,8 @@ def _handle_file_save(handler, body):
return bad(handler, "File not found", 404)
if target.is_dir():
return bad(handler, "Cannot save: path is a directory")
if Path(str(body["path"])).suffix.lower() in {".docx", ".xlsx", ".pptx"}:
return bad(handler, "Use /api/file/office-save for Office documents")
data = str(body.get("content", "")).encode("utf-8")
fd = open_anchored_write_fd(ws_root, target)
with os.fdopen(fd, "wb", closefd=True) as fh:
@ -20811,6 +20833,41 @@ def _handle_file_save(handler, body):
return bad(handler, _sanitize_error(e))
def _handle_office_file_save(handler, body):
try:
require(body, "session_id", "path")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session_for_file_ops(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
try:
ws_root = Path(s.workspace)
target = safe_resolve(ws_root, body["path"])
if (ws_root / body["path"]).is_symlink():
return bad(handler, "Cannot save to a symlinked entry")
if not target.exists():
return bad(handler, "File not found", 404)
if target.is_dir():
return bad(handler, "Cannot save: path is a directory")
if Path(str(body["path"])).suffix.lower() not in {".docx", ".xlsx", ".pptx"}:
return bad(handler, "Office save is only available for .docx, .xlsx, and .pptx files")
from api.office_documents import save_office_document
current_bytes = _read_anchored_file_bytes(ws_root, target)
preview, updated_bytes = save_office_document(body["path"], current_bytes, body.get("content", ""))
fd = open_anchored_write_fd(ws_root, target)
with os.fdopen(fd, "wb", closefd=True) as fh:
fh.write(updated_bytes)
preview.update({"ok": True, "path": body["path"], "size": len(updated_bytes)})
return j(handler, preview)
except ImportError as e:
return bad(handler, str(e), 503)
except (ValueError, FileNotFoundError, PermissionError, OSError) as e:
return bad(handler, _sanitize_error(e))
def _handle_file_create(handler, body):
try:
require(body, "session_id", "path")

View file

@ -1471,6 +1471,10 @@ def read_file_content(workspace: Path, rel: str) -> dict:
if st.st_size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
raw = fh.read(MAX_FILE_BYTES + 1)
if Path(str(rel)).suffix.lower() in {".docx", ".xlsx", ".pptx"}:
from api.office_documents import preview_office_document
return preview_office_document(rel, raw)
content = raw.decode('utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1}

View file

@ -9,3 +9,10 @@ pytest-asyncio
pytest-shard
ruff
mcp
# Optional Office parsers — runtime-optional (commented in requirements.txt), but
# installed here so the workspace Office-preview tests (tests/test_issue540_*.py)
# run under the documented ./scripts/test.sh entry point. These files
# pytest.importorskip the parsers, so they skip cleanly if these are removed.
python-docx
openpyxl
python-pptx

View file

@ -12,3 +12,7 @@
# CPU/RAM metrics on platforms without /proc: pip install psutil>=5.9
pyyaml>=6.0
cryptography>=42.0
# OPTIONAL: Office workspace preview/edit support needs these parsers.
# The workspace file routes return 503 with an install hint when they are absent.
# Install them only if you want .docx/.xlsx/.pptx preview support:
# pip install python-docx>=1.1.2 openpyxl>=3.1.5 python-pptx>=1.0.2

View file

@ -778,7 +778,7 @@ const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 256 * 1024;
const MD_PREVIEW_RICH_RENDER_MAX_LINES = 5000;
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
'.doc','.xls','.ppt','.odt','.ods','.odp',
'.zip','.tar','.gz','.bz2','.7z','.rar',
'.exe','.dmg','.pkg','.deb','.rpm',
'.woff','.woff2','.ttf','.otf','.eot',
@ -882,6 +882,10 @@ function forceRenderMarkdownPreview(){
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'csv' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
let _previewDirty = false; // true when edits are unsaved
let _previewServerEditable = null; // backend editability metadata when available
let _previewSaveRoute = '/api/file/save'; // current save adapter for the open preview
let _previewOfficeFormat = ''; // current claimed Office format, if any
let _previewPreviewKind = ''; // preview family returned by the backend
function showPreview(mode){
// mode: 'code' | 'csv' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
@ -908,7 +912,9 @@ function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = !_workspacePathIsReadOnly(_previewCurrentPath)
&& (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv');
&& (_previewServerEditable===null
? (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv')
: !!_previewServerEditable);
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
@ -923,23 +929,34 @@ async function toggleEditMode(){
showToast(t('external_link_read_only'), 2000);
return;
}
if(!editing && _previewServerEditable===false){
showToast('This Office document is preview-only.', 3000, 'error');
return;
}
if(editing){
// Save
if(!S.session||!_previewCurrentPath)return;
const content=$('previewEditArea').value;
try{
await api('/api/file/save',{method:'POST',body:JSON.stringify({
const saved=await api(_previewSaveRoute||'/api/file/save',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, path:_previewCurrentPath, content
})});
const savedContent=saved&&typeof saved.content==='string'?saved.content:content;
if(saved && typeof saved.editable==='boolean') _previewServerEditable = saved.editable;
if(saved && saved.preview_kind) _previewPreviewKind = saved.preview_kind;
if(saved && saved.office_format) _previewOfficeFormat = saved.office_format;
if(saved && saved.preview_kind==='office' && saved.office_format==='docx'){
_previewSaveRoute = '/api/file/office-save';
}
_previewDirty=false;
// Update read-only views AND the cached raw content so a later
// "Render as markdown anyway" force-render reflects the just-saved text
// (not the stale pre-edit fetch). #3378 review (Codex).
_previewRawContent = content;
_previewRawContent = savedContent;
_previewRawContentPath = _previewCurrentPath;
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, content);
else renderMarkdownPreviewContent({content});
if(_previewCurrentMode==='code') $('previewCode').textContent=savedContent;
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, savedContent);
else renderMarkdownPreviewContent({content:savedContent});
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
@ -1022,6 +1039,11 @@ async function openFile(path, opts={}){
return;
}
_previewServerEditable = null;
_previewSaveRoute = '/api/file/save';
_previewOfficeFormat = '';
_previewPreviewKind = '';
$('previewPathText').textContent=path;
$('previewArea').classList.add('visible');
$('fileTree').style.display='none';
@ -1114,6 +1136,14 @@ async function openFile(path, opts={}){
downloadFile(path);
return;
}
if(data.preview_kind==='office'){
_previewRawContent = data.content || '';
_previewRawContentPath = path;
_previewServerEditable = typeof data.editable === 'boolean' ? data.editable : null;
_previewPreviewKind = data.preview_kind || '';
_previewOfficeFormat = data.office_format || '';
_previewSaveRoute = data.preview_kind==='office' ? '/api/file/office-save' : '/api/file/save';
}
renderCodePreviewContent(path, data.content);
}catch(e){
const grant = _workspaceEscapeGrantForPath(path);

View file

@ -198,6 +198,6 @@ def test_csv_preview_preserves_edit_flow():
assert '_previewRawContent = content' in csv_render or '_previewRawContent=content' in csv_render, \
"renderCsvPreviewContent must cache raw CSV content for the edit flow"
# save path re-renders the CSV table for csv mode
save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 1200]
assert "renderCsvPreviewContent(_previewCurrentPath, content)" in save_section, \
save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 2200]
assert "renderCsvPreviewContent(_previewCurrentPath, savedContent)" in save_section, \
"saving an edited CSV must re-render the table, not markdown"

View file

@ -120,10 +120,11 @@ def test_save_updates_cached_raw_content_for_force_render():
"""#3378 review (Codex): saving a markdown file from the plain-text fallback
must refresh _previewRawContent (and its path) so a later force-render shows the
saved text, not the stale pre-edit fetch."""
save_idx = WORKSPACE_JS.find("await api('/api/file/save'")
save_idx = WORKSPACE_JS.find("const saved=await api(_previewSaveRoute||'/api/file/save'")
assert save_idx != -1, "file save call not found"
save_block = WORKSPACE_JS[save_idx:save_idx + 600]
assert "_previewRawContent = content" in save_block
save_block = WORKSPACE_JS[save_idx:save_idx + 1500]
assert "const savedContent=saved&&typeof saved.content==='string'?saved.content:content;" in save_block
assert "_previewRawContent = savedContent" in save_block
assert "_previewRawContentPath = _previewCurrentPath" in save_block

View file

@ -0,0 +1,707 @@
from __future__ import annotations
import io
import json
import shutil
import subprocess
import zipfile
from pathlib import Path
import pytest
# The Office parsers are optional deps (commented-optional in requirements.txt;
# installed by CI and requirements-dev.txt). On a lean install they are absent,
# so importorskip these modules to skip this file cleanly instead of aborting
# collection for the WHOLE suite with a module-level ImportError (repo idiom —
# see the ~12 other test files that importorskip an optional dependency).
pytest.importorskip("docx")
pytest.importorskip("openpyxl")
pytest.importorskip("pptx")
from docx import Document as DocxDocument
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from openpyxl import Workbook
from pptx import Presentation
from pptx.util import Inches
import api.office_documents as office_documents
from api.office_documents import (
CLAIMED_OFFICE_EXTENSIONS,
is_claimed_office_path,
preview_office_document,
save_office_document,
)
ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _simple_docx_bytes(*paragraphs: str) -> bytes:
document = DocxDocument()
for paragraph in paragraphs or ("",):
document.add_paragraph(paragraph)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _rich_docx_bytes() -> bytes:
document = DocxDocument()
document.add_paragraph("Lead paragraph")
table = document.add_table(rows=1, cols=2)
table.cell(0, 0).text = "left"
table.cell(0, 1).text = "right"
document.add_paragraph("Tail paragraph")
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _formatted_docx_bytes() -> bytes:
document = DocxDocument()
run = document.add_paragraph().add_run("Styled text")
run.bold = True
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _paragraph_style_docx_bytes(style: str) -> bytes:
document = DocxDocument()
paragraph = document.add_paragraph("Styled paragraph")
properties = paragraph._p.get_or_add_pPr()
paragraph_style = OxmlElement("w:pStyle")
paragraph_style.set(qn("w:val"), style)
properties.append(paragraph_style)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _header_docx_bytes() -> bytes:
document = DocxDocument()
document.add_paragraph("Body text")
document.sections[0].header.paragraphs[0].text = "Header text"
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _custom_section_docx_bytes() -> bytes:
document = DocxDocument()
document.add_paragraph("Body text")
document.sections[0].left_margin = Inches(2)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _simple_xlsx_bytes() -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "Summary"
sheet["A1"] = "alpha"
sheet["B1"] = "beta"
sheet["A2"] = "gamma"
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _simple_pptx_bytes() -> bytes:
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
box.text = "Office preview"
buffer = io.BytesIO()
presentation.save(buffer)
return buffer.getvalue()
def _office_zip_bytes(*members: tuple[str, str | bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", "<Types/>")
for name, payload in members:
archive.writestr(name, payload)
return buffer.getvalue()
def _multi_slide_pptx_bytes() -> bytes:
presentation = Presentation()
for index in range(2):
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
box.text = f"Slide {index + 1}"
buffer = io.BytesIO()
presentation.save(buffer)
return buffer.getvalue()
def test_office_registry_claims_docx_xlsx_pptx():
assert CLAIMED_OFFICE_EXTENSIONS == {".docx", ".xlsx", ".pptx"}
assert is_claimed_office_path("report.docx")
assert is_claimed_office_path("budget.xlsx")
assert is_claimed_office_path("deck.pptx")
docx_preview = preview_office_document("report.docx", _simple_docx_bytes("one", "two"))
xlsx_preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
pptx_preview = preview_office_document("deck.pptx", _simple_pptx_bytes())
assert docx_preview["preview_kind"] == "office"
assert xlsx_preview["preview_kind"] == "office"
assert pptx_preview["preview_kind"] == "office"
assert docx_preview["office_format"] == "docx"
assert xlsx_preview["office_format"] == "xlsx"
assert pptx_preview["office_format"] == "pptx"
assert docx_preview["render_mode"] == "code"
assert xlsx_preview["render_mode"] == "code"
assert pptx_preview["render_mode"] == "code"
assert docx_preview["editable"] is True
assert xlsx_preview["editable"] is False
assert pptx_preview["editable"] is False
def test_docx_paragraph_projection_round_trips_simple_documents():
original_bytes = _simple_docx_bytes("alpha", "beta")
preview = preview_office_document("story.docx", original_bytes)
assert preview["editable"] is True
assert preview["content"] == "alpha\nbeta"
saved_preview, saved_bytes = save_office_document("story.docx", original_bytes, "alpha\nbeta\ngamma")
round_trip = DocxDocument(io.BytesIO(saved_bytes))
assert saved_preview["editable"] is True
assert saved_preview["content"] == "alpha\nbeta\ngamma"
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["alpha", "beta", "gamma"]
def test_docx_preview_preserves_interleaved_table_order():
preview = preview_office_document("report.docx", _rich_docx_bytes())
assert preview["content"] == "Lead paragraph\nTable 1\nleft\tright\nTail paragraph"
def test_docx_with_explicit_normal_style_stays_editable():
original_bytes = _paragraph_style_docx_bytes("Normal")
preview = preview_office_document("styled.docx", original_bytes)
assert preview["editable"] is True
assert preview["content"] == "Styled paragraph"
saved_preview, saved_bytes = save_office_document("styled.docx", original_bytes, "Edited paragraph")
round_trip = DocxDocument(io.BytesIO(saved_bytes))
assert saved_preview["editable"] is True
assert saved_preview["content"] == "Edited paragraph"
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["Edited paragraph"]
def test_docx_with_non_default_paragraph_style_stays_preview_only():
preview = preview_office_document("heading.docx", _paragraph_style_docx_bytes("Heading1"))
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
def test_docx_preview_truncation_disables_editing(monkeypatch):
monkeypatch.setattr(office_documents, "MAX_DOCX_PREVIEW_BLOCKS", 1)
monkeypatch.setattr(
office_documents,
"_docx_editability",
lambda _document: pytest.fail("truncated docx preview should not run full editability scan"),
)
preview = preview_office_document("story.docx", _simple_docx_bytes("alpha", "beta"))
assert preview["truncated"] is True
assert preview["editable"] is False
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
def test_docx_preview_char_budget_disables_editing(monkeypatch):
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 5)
monkeypatch.setattr(
office_documents,
"_docx_editability",
lambda _document: pytest.fail("char-budget truncation should not run full editability scan"),
)
preview = preview_office_document("story.docx", _simple_docx_bytes("alphabet", "beta"))
assert preview["truncated"] is True
assert preview["editable"] is False
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
@pytest.mark.parametrize(
("path", "member_name", "loader_attr"),
[
("story.docx", "word/document.xml", "_load_docx_document"),
("budget.xlsx", "xl/sharedStrings.xml", "_load_workbook_reader"),
("deck.pptx", "ppt/media/image1.png", "_load_presentation_ctor"),
],
)
def test_office_preview_preflight_rejects_zip_bombs_before_loader(
monkeypatch, path: str, member_name: str, loader_attr: str
):
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
monkeypatch.setattr(
office_documents,
loader_attr,
lambda: lambda *_args, **_kwargs: pytest.fail("office parser should not run after archive preflight rejects the payload"),
)
raw = _office_zip_bytes((member_name, "A" * 4096))
with pytest.raises(ValueError, match="safe archive limits"):
preview_office_document(path, raw)
def test_office_preview_preflight_rejects_path_traversal_before_loader(monkeypatch):
monkeypatch.setattr(
office_documents,
"_load_docx_document",
lambda: lambda *_args, **_kwargs: pytest.fail("docx parser should not run after path validation fails"),
)
raw = _office_zip_bytes(("../word/document.xml", "payload"))
with pytest.raises(ValueError, match="safe archive limits"):
preview_office_document("story.docx", raw)
def test_xlsx_preview_preflight_rejects_oversized_shared_strings_before_loader(monkeypatch):
monkeypatch.setattr(office_documents, "MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES", 64)
monkeypatch.setattr(
office_documents,
"_load_workbook_reader",
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run after sharedStrings preflight rejects the payload"),
)
raw = _office_zip_bytes(("xl/sharedStrings.xml", "A" * 256))
with pytest.raises(ValueError, match="safe archive limits"):
preview_office_document("budget.xlsx", raw)
def test_xlsx_preview_preflight_measures_actual_bytes_when_zip_claims_zero(monkeypatch):
class FakeInfo:
filename = "xl/sharedStrings.xml"
file_size = 0
compress_size = 1
@staticmethod
def is_dir():
return False
class FakeArchive:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
@staticmethod
def infolist():
return [FakeInfo()]
@staticmethod
def open(_info):
return io.BytesIO(b"A" * 2048)
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
monkeypatch.setattr(office_documents.zipfile, "ZipFile", lambda *_args, **_kwargs: FakeArchive())
monkeypatch.setattr(
office_documents,
"_load_workbook_reader",
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run when actual inflated bytes exceed the preflight cap"),
)
with pytest.raises(ValueError, match="safe archive limits"):
preview_office_document("budget.xlsx", b"placeholder")
def _office_state_block() -> str:
marker = "if(data.preview_kind==='office'){"
start = WORKSPACE_JS.find(marker)
assert start >= 0, "office preview state block not found in static/workspace.js"
depth = 0
for idx in range(start, len(WORKSPACE_JS)):
char = WORKSPACE_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return WORKSPACE_JS[start:idx + 1]
raise AssertionError("could not find balanced office preview state block")
def _run_office_state_block(payload: dict) -> dict:
js = (
"const data = " + json.dumps(payload) + ";\n"
+ "const path = 'report';\n"
+ "let _previewRawContent = null;\n"
+ "let _previewRawContentPath = null;\n"
+ "let _previewServerEditable = null;\n"
+ "let _previewPreviewKind = '';\n"
+ "let _previewOfficeFormat = '';\n"
+ "let _previewSaveRoute = '/api/file/save';\n"
+ _office_state_block()
+ "\nconsole.log(JSON.stringify({_previewRawContent,_previewRawContentPath,_previewServerEditable,_previewPreviewKind,_previewOfficeFormat,_previewSaveRoute}));\n"
)
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
return json.loads(result.stdout.strip())
def test_xlsx_stays_preview_only():
path = "budget.xlsx"
raw = _simple_xlsx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
def test_xlsx_preview_is_bounded(monkeypatch):
monkeypatch.setattr(office_documents, "MAX_XLSX_PREVIEW_ROWS_PER_SHEET", 1)
preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
assert preview["truncated"] is True
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
def test_xlsx_preview_stops_when_char_budget_is_exhausted(monkeypatch):
class FakeSheet:
title = "Summary"
max_row = 9_999
max_column = 9_999
def iter_rows(self, *, values_only, max_row, max_col):
assert values_only is True
assert max_row == office_documents.MAX_XLSX_PREVIEW_ROWS_PER_SHEET
assert max_col == office_documents.MAX_XLSX_PREVIEW_CELLS_PER_SHEET
yield ("x" * 200, "y" * 200)
pytest.fail("xlsx preview should stop after the preview budget is exhausted")
class FakeWorkbook:
def __init__(self):
self.worksheets = [FakeSheet()]
def close(self):
return None
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 32)
monkeypatch.setattr(office_documents, "_preflight_office_archive", lambda *_args, **_kwargs: None)
monkeypatch.setattr(office_documents, "_load_workbook_reader", lambda: lambda *_args, **_kwargs: FakeWorkbook())
preview = preview_office_document("budget.xlsx", b"placeholder")
assert preview["truncated"] is True
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
def test_pptx_stays_preview_only():
path = "deck.pptx"
raw = _simple_pptx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
def test_pptx_preview_is_bounded(monkeypatch):
monkeypatch.setattr(office_documents, "MAX_PPTX_PREVIEW_SLIDES", 1)
preview = preview_office_document("deck.pptx", _multi_slide_pptx_bytes())
assert preview["truncated"] is True
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
def test_rich_docx_stays_preview_only():
path = "rich.docx"
raw = _rich_docx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
def test_formatted_docx_stays_preview_only():
path = "formatted.docx"
raw = _formatted_docx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
def test_header_docx_stays_preview_only():
path = "headed.docx"
raw = _header_docx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
def test_custom_section_docx_stays_preview_only():
path = "custom-section.docx"
raw = _custom_section_docx_bytes()
preview = preview_office_document(path, raw)
assert preview["preview_kind"] == "office"
assert preview["editable"] is False
assert preview.get("edit_blocked_reason")
with pytest.raises(ValueError):
save_office_document(path, raw, "edited text")
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_workspace_office_state_block_routes_all_office_formats_through_office_save():
xlsx_state = _run_office_state_block(
{
"preview_kind": "office",
"office_format": "xlsx",
"editable": False,
"content": "sheet preview",
}
)
docx_state = _run_office_state_block(
{
"preview_kind": "office",
"office_format": "docx",
"editable": True,
"content": "doc preview",
}
)
assert xlsx_state["_previewSaveRoute"] == "/api/file/office-save"
assert xlsx_state["_previewServerEditable"] is False
assert xlsx_state["_previewOfficeFormat"] == "xlsx"
assert docx_state["_previewSaveRoute"] == "/api/file/office-save"
assert docx_state["_previewServerEditable"] is True
# ---------------------------------------------------------------------------
# Regression coverage for the four blockers found in the v0.51.802 gate review
# (all reproduced against legitimate-but-common inputs the earlier adversarial
# rounds never exercised).
# ---------------------------------------------------------------------------
def _write_only_xlsx_bytes() -> bytes:
"""A workbook produced by openpyxl's streaming writer.
``Workbook(write_only=True)`` omits the ``<dimension>`` record, so when the
preview re-opens it in read_only mode ``sheet.max_row``/``max_column`` are
``None``. This is exactly issue #540's own use case (agents streaming large
sheets) and previously crashed the preview with a ``TypeError`` 500.
"""
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet("Streamed")
sheet.append(["alpha", "beta", "gamma"])
sheet.append(["one", "two", "three"])
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _multi_run_docx_bytes() -> bytes:
"""A docx whose paragraph spans multiple runs with a run-boundary space.
``add_run("Hello ") + add_run("world")`` is ubiquitous in real Word files
(and trivially produced by agent code). Each run is a separate ``<w:t>``
node; the preview previously ``.strip()``-ed each node and concatenated with
no separator, corrupting the text to "Helloworld".
"""
document = DocxDocument()
paragraph = document.add_paragraph()
paragraph.add_run("Hello ")
paragraph.add_run("world")
document.add_paragraph("Second paragraph.")
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def test_write_only_xlsx_without_dimension_previews_without_crash():
"""BLOCKING 1: dimension-less xlsx (max_row/max_column None) must not 500."""
raw = _write_only_xlsx_bytes()
# Must not raise TypeError — the pre-fix code did `min(None, CAP)`.
preview = preview_office_document("streamed.xlsx", raw)
assert preview["preview_kind"] == "office"
assert preview["office_format"] == "xlsx"
# The streamed cell values must actually appear in the preview.
assert "alpha" in preview["content"]
assert "three" in preview["content"]
def test_multi_run_docx_preserves_run_boundary_whitespace():
"""BLOCKING 2 (preview): run-boundary space must survive extraction."""
raw = _multi_run_docx_bytes()
preview = preview_office_document("multi-run.docx", raw)
assert "Hello world" in preview["content"]
assert "Helloworld" not in preview["content"]
def test_multi_run_docx_round_trips_text_identically_on_save():
"""BLOCKING 2 (save): opening the editor and saving unchanged preview text
must NOT corrupt the on-disk document. This is the data-loss path: the
editor textarea is prefilled from the preview text, so a no-op save writes
the preview text back verbatim."""
raw = _multi_run_docx_bytes()
preview = preview_office_document("multi-run.docx", raw)
assert preview["editable"] is True
# Simulate a save with the UNMODIFIED preview text (the no-op-save path).
_saved_preview, saved_bytes = save_office_document(
"multi-run.docx", raw, preview["content"]
)
# Read the saved bytes back and confirm the paragraph text is intact.
reloaded = DocxDocument(io.BytesIO(saved_bytes))
paragraph_texts = [p.text for p in reloaded.paragraphs]
assert "Hello world" in paragraph_texts
assert "Helloworld" not in paragraph_texts
def test_corrupt_archive_member_raises_value_error_not_unhandled():
"""BLOCKING 3: a valid zip central directory with corrupt member DATA (bad
CRC / truncated) must raise the module's ValueError (handled as a clean
error) instead of an unhandled BadZipFile/zlib.error that escapes as a 500."""
raw = bytearray(_simple_docx_bytes("hello"))
# Corrupt the deflate stream bytes without touching the central directory,
# so the archive opens but a member.read() fails a CRC/zlib check. Flip a
# run of bytes in the middle of the compressed payload region.
start = len(raw) // 3
for i in range(start, start + 64):
raw[i] ^= 0xFF
with pytest.raises(ValueError):
preview_office_document("corrupt.docx", bytes(raw))
def test_archive_limit_error_still_raised_over_corrupt_catch():
"""BLOCKING 3 guard: the limit-exceeded ValueError raised INSIDE the member
read loop must not be swallowed by the new corrupt-member except clause
(it catches only decompression exceptions, never ValueError)."""
# A docx whose main document part inflates beyond the per-member cap.
document = DocxDocument()
document.add_paragraph("x" * 5_000)
buffer = io.BytesIO()
document.save(buffer)
raw = buffer.getvalue()
# Temporarily force a tiny member cap so the real (well-formed) archive
# trips the limit path — proving the limit ValueError propagates.
original = office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = 10
try:
with pytest.raises(ValueError):
preview_office_document("big.docx", raw)
finally:
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = original
def _authored_docx_bytes() -> bytes:
"""A docx carrying core-properties metadata and body text (like every
Word-authored file)."""
document = DocxDocument()
document.core_properties.author = "Jane Author"
document.core_properties.title = "Quarterly Report"
document.add_paragraph("Body text here")
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def test_unedited_docx_save_preserves_core_properties():
"""Round-trip fidelity: an unedited open->save must NOT wipe author/title.
The pre-fix save rebuilt the package from python-docx's blank template, so
metadata/styles were silently lost even when the user changed nothing (the
editor prefills the textarea from the preview). Rebuilding from the current
package preserves docProps/styles/theme."""
raw = _authored_docx_bytes()
preview = preview_office_document("authored.docx", raw)
assert preview["editable"] is True
_saved_preview, saved_bytes = save_office_document(
"authored.docx", raw, preview["content"]
)
reloaded = DocxDocument(io.BytesIO(saved_bytes))
assert reloaded.core_properties.author == "Jane Author"
assert reloaded.core_properties.title == "Quarterly Report"
assert "Body text here" in [p.text for p in reloaded.paragraphs]
def test_oversized_docx_save_content_rejected_before_build():
"""Write-surface DoS guard: content exceeding the editable char/line caps is
rejected up front, before the quadratic add_paragraph build loop runs."""
raw = _authored_docx_bytes()
# Exceed the paragraph-count cap (MAX_DOCX_PREVIEW_BLOCKS).
too_many_lines = "\n".join("x" for _ in range(office_documents.MAX_DOCX_PREVIEW_BLOCKS + 50))
with pytest.raises(ValueError):
save_office_document("authored.docx", raw, too_many_lines)
# Exceed the char cap with few lines.
too_many_chars = "a" * (office_documents.MAX_OFFICE_PREVIEW_CHARS + 1)
with pytest.raises(ValueError):
save_office_document("authored.docx", raw, too_many_chars)
@pytest.mark.parametrize(
"paragraphs",
[
["Hello", "", ""], # trailing blank paragraphs (normal Word convention)
["", "World"], # leading blank paragraph
["a", "", "b"], # interior blank (already worked — guard against regression)
],
)
def test_unedited_docx_save_preserves_edge_blank_paragraphs(paragraphs):
"""Round-trip fidelity: leading/trailing blank paragraphs must survive an
unedited open->save. _finalize_preview_text used to .strip() the whole
preview, and since the editor textarea is prefilled from the preview text,
a no-op save silently dropped edge blank paragraphs. The docx preview path
now preserves edge whitespace (strip_edges=False)."""
document = DocxDocument()
for text in paragraphs:
document.add_paragraph(text)
buffer = io.BytesIO()
document.save(buffer)
raw = buffer.getvalue()
preview = preview_office_document("edge.docx", raw)
assert preview["editable"] is True
_saved_preview, saved_bytes = save_office_document("edge.docx", raw, preview["content"])
reloaded = [p.text for p in DocxDocument(io.BytesIO(saved_bytes)).paragraphs]
assert reloaded == paragraphs

View file

@ -0,0 +1,240 @@
from __future__ import annotations
import io
import json
from pathlib import Path
import shutil
import subprocess
from types import SimpleNamespace
from urllib.parse import urlparse
import pytest
# Optional Office parsers — importorskip so a lean install (parsers absent)
# skips this file cleanly rather than aborting collection suite-wide.
pytest.importorskip("docx")
pytest.importorskip("openpyxl")
from docx import Document as DocxDocument
from openpyxl import Workbook
import api.office_documents as office_documents
import api.routes as routes
ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _simple_docx_bytes(*paragraphs: str) -> bytes:
document = DocxDocument()
for paragraph in paragraphs or ("",):
document.add_paragraph(paragraph)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _simple_xlsx_bytes() -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet["A1"] = "alpha"
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _extract_workspace_block(start_marker: str, end_marker: str) -> str:
start = WORKSPACE_JS.find(start_marker)
assert start >= 0, f"{start_marker!r} not found in static/workspace.js"
end = WORKSPACE_JS.find(end_marker, start)
assert end >= 0, f"{end_marker!r} not found after {start_marker!r}"
return WORKSPACE_JS[start:end]
def _extract_workspace_function(name: str) -> str:
marker = f"async function {name}("
start = WORKSPACE_JS.find(marker)
assert start >= 0, f"{name} not found in static/workspace.js"
params_depth = 0
body_start = -1
for idx in range(start, len(WORKSPACE_JS)):
char = WORKSPACE_JS[idx]
if char == "(":
params_depth += 1
elif char == ")":
params_depth -= 1
elif char == "{" and params_depth == 0:
body_start = idx
break
assert body_start >= 0, f"could not find function body for {name}"
depth = 0
for idx in range(body_start, len(WORKSPACE_JS)):
char = WORKSPACE_JS[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return WORKSPACE_JS[start:idx + 1]
raise AssertionError(f"could not find balanced function body for {name}")
def _patch_file_ops(monkeypatch, workspace: Path):
session = SimpleNamespace(workspace=str(workspace))
captured = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured["ok"] = payload
captured["status"] = status
return True
def fake_bad(handler, message, status=400):
captured["bad"] = (message, status)
return True
monkeypatch.setattr(routes, "get_session_for_file_ops", lambda sid: session)
monkeypatch.setattr(routes, "j", fake_j)
monkeypatch.setattr(routes, "bad", fake_bad)
return captured
def test_file_read_returns_office_preview_payload_for_docx(tmp_path, monkeypatch):
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha", "beta"))
captured = _patch_file_ops(monkeypatch, workspace)
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
payload = captured["ok"]
assert payload["preview_kind"] == "office"
assert payload["office_format"] == "docx"
assert payload["render_mode"] == "code"
assert payload["editable"] is True
assert payload["content"] == "alpha\nbeta"
def test_file_read_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
captured = _patch_file_ops(monkeypatch, workspace)
def fail_read(*_args, **_kwargs):
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
monkeypatch.setattr(routes, "read_file_content", fail_read)
routes._handle_file_read(object(), urlparse("/api/file?session_id=sid&path=story.docx"))
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
def test_office_save_route_accepts_safe_docx_and_rejects_preview_only_formats(tmp_path, monkeypatch):
workspace = tmp_path / "ws"
workspace.mkdir()
docx_path = workspace / "story.docx"
docx_path.write_bytes(_simple_docx_bytes("alpha", "beta"))
xlsx_path = workspace / "budget.xlsx"
xlsx_path.write_bytes(_simple_xlsx_bytes())
captured = _patch_file_ops(monkeypatch, workspace)
routes._handle_office_file_save(
object(),
{"session_id": "sid", "path": "story.docx", "content": "alpha\nbeta\ngamma"},
)
saved = captured["ok"]
assert saved["preview_kind"] == "office"
assert saved["office_format"] == "docx"
assert saved["editable"] is True
assert saved["content"] == "alpha\nbeta\ngamma"
assert [paragraph.text for paragraph in DocxDocument(io.BytesIO(docx_path.read_bytes())).paragraphs] == [
"alpha",
"beta",
"gamma",
]
captured.clear()
routes._handle_office_file_save(
object(),
{"session_id": "sid", "path": "budget.xlsx", "content": "ignored"},
)
assert captured["bad"][1] == 400
assert "preview-only" in captured["bad"][0]
def test_office_save_route_returns_503_when_office_parsers_are_missing(tmp_path, monkeypatch):
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "story.docx").write_bytes(_simple_docx_bytes("alpha"))
captured = _patch_file_ops(monkeypatch, workspace)
def fail_save(*_args, **_kwargs):
raise ImportError(office_documents.OFFICE_DEPENDENCY_HINT)
monkeypatch.setattr(office_documents, "save_office_document", fail_save)
routes._handle_office_file_save(
object(),
{"session_id": "sid", "path": "story.docx", "content": "beta"},
)
assert captured["bad"] == (office_documents.OFFICE_DEPENDENCY_HINT, 503)
def test_workspace_js_docx_routes_through_file_read_not_download():
assert "'.docx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
assert "'.xlsx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
assert "'.pptx'" not in WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
assert "'.doc'" in WORKSPACE_JS
assert "'.xls'" in WORKSPACE_JS
assert "'.ppt'" in WORKSPACE_JS
assert "data.preview_kind==='office'" in WORKSPACE_JS
assert "/api/file/office-save" in WORKSPACE_JS
def test_workspace_js_edit_button_uses_server_editable_flag():
assert "_previewServerEditable" in WORKSPACE_JS
assert "_previewServerEditable===false" in WORKSPACE_JS
assert "This Office document is preview-only." in WORKSPACE_JS
assert "_previewSaveRoute" in WORKSPACE_JS
assert "_previewSaveRoute = data.preview_kind==='office' ? '/api/file/office-save' : '/api/file/save';" in WORKSPACE_JS
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_openfile_download_only_path_preserves_existing_office_save_route():
js = (
_extract_workspace_block("const DOWNLOAD_EXTS = new Set([", "function fileExt")
+ "function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }\n"
+ _extract_workspace_function("openFile")
+ "\nconst S = { session: { session_id: 'sid-1' } };\n"
+ "let _previewServerEditable = true;\n"
+ "let _previewSaveRoute = '/api/file/office-save';\n"
+ "let _previewOfficeFormat = 'docx';\n"
+ "let _previewPreviewKind = 'office';\n"
+ "let downloaded = null;\n"
+ "function downloadFile(path){ downloaded = path; }\n"
+ "(async()=>{ await openFile('legacy.doc'); console.log(JSON.stringify({downloaded,_previewServerEditable,_previewSaveRoute,_previewOfficeFormat,_previewPreviewKind})); })();\n"
)
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
state = json.loads(result.stdout.strip().splitlines()[-1])
assert state["downloaded"] == "legacy.doc"
assert state["_previewSaveRoute"] == "/api/file/office-save"
assert state["_previewServerEditable"] is True
assert state["_previewOfficeFormat"] == "docx"
assert state["_previewPreviewKind"] == "office"
def test_pdf_preview_path_is_unchanged():
assert "showPreview('pdf')" in WORKSPACE_JS
assert "_workspaceRouteForPath(path, 'raw', {inline:true})" in WORKSPACE_JS
def test_zip_and_legacy_office_formats_still_download():
download_block = WORKSPACE_JS.split("const DOWNLOAD_EXTS = new Set([", 1)[1].split("]);", 1)[0]
for ext in [".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".zip", ".tar"]:
assert ext in download_block