diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f91d35b..aa2acc79 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a54621de..270524f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/office_documents.py b/api/office_documents.py new file mode 100644 index 00000000..f3eefd38 --- /dev/null +++ b/api/office_documents.py @@ -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 ```` 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 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 diff --git a/api/routes.py b/api/routes.py index 7bb5ebec..61c79358 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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") diff --git a/api/workspace.py b/api/workspace.py index b56979ad..8f03b490 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -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} diff --git a/requirements-dev.txt b/requirements-dev.txt index d51d3ef7..d6b5ca39 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index 6e1b6ee0..8dea8fef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/workspace.js b/static/workspace.js index 09ca4eae..296d6be7 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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 ? `💾 ${t('save')}` : `✎ ${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); diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py index 57cbbe9b..02c1eb52 100644 --- a/tests/test_csv_table_rendering.py +++ b/tests/test_csv_table_rendering.py @@ -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" diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py index 7099e7aa..99a899a7 100644 --- a/tests/test_issue2823_large_markdown_preview.py +++ b/tests/test_issue2823_large_markdown_preview.py @@ -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 diff --git a/tests/test_issue540_workspace_office_documents.py b/tests/test_issue540_workspace_office_documents.py new file mode 100644 index 00000000..04b611ae --- /dev/null +++ b/tests/test_issue540_workspace_office_documents.py @@ -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", "") + 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 ```` 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 ```` + 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 diff --git a/tests/test_issue540_workspace_office_preview.py b/tests/test_issue540_workspace_office_preview.py new file mode 100644 index 00000000..bf6ffae1 --- /dev/null +++ b/tests/test_issue540_workspace_office_preview.py @@ -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