From aea948fd4dfcbd33debd1f33d959583216d306af Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 07:36:21 +0000 Subject: [PATCH] Reject structurally broken OOXML at artifact registration Why: An agent bypassing the housestyle kit hand-assembled OOXML with zipfile; the resulting pptx lacked masters, theme and docProps. Text extraction succeeded and every existing check passed, so a package that Office refuses to open reached the user as a deliverable. Evidence: Focused Go suite covers a valid minimal pptx, the incident- shaped package, nine chain-break variants and register-level integration; the housestyle python suite adds BrokenPptxTests for masterless, kit-made and unopenable files. Full combined suite receipt follows on the task card before review. Impact: register_artifact now runs a pure-Go OPC structural check for docx/xlsx/pptx and refuses broken packages with a kit rebuild hint; lint_pptx gains structure-broken assertions (master/theme/layout chain, open-failure fallback) that template mode cannot downgrade; prompts forbid hand-assembled OOXML and specify the missing-library fallback. Refs: Dev task feature/pptx-register-structural-gate --- internal/prompts/library/base-deliverables.md | 3 +- internal/prompts/library/tool-run-python.md | 4 +- .../recipes/house-style/housestyle/lint.py | 67 +++-- .../house-style/tests/test_housestyle.py | 50 ++++ internal/tools/exec.go | 3 + internal/tools/ooxmlgate.go | 269 ++++++++++++++++++ internal/tools/ooxmlgate_test.go | 221 ++++++++++++++ 7 files changed, 596 insertions(+), 21 deletions(-) create mode 100644 internal/tools/ooxmlgate.go create mode 100644 internal/tools/ooxmlgate_test.go diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index 2235dcc..2d3ddd8 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -1,7 +1,7 @@ --- id: base.deliverables layer: base -version: 7 +version: 8 title: 交付物工作流 description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座) --- @@ -38,6 +38,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技 - **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。 - 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。 - 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。 +- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。 要求与边界: diff --git a/internal/prompts/library/tool-run-python.md b/internal/prompts/library/tool-run-python.md index 4e24b92..e45b563 100644 --- a/internal/prompts/library/tool-run-python.md +++ b/internal/prompts/library/tool-run-python.md @@ -1,8 +1,8 @@ --- id: tool.run_python layer: tool -version: 1 +version: 2 title: run_python 工具描述 description: Python 执行原语的模型侧说明文案 --- -执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。 +执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。 diff --git a/internal/skills/recipes/house-style/housestyle/lint.py b/internal/skills/recipes/house-style/housestyle/lint.py index 0163705..916e53c 100644 --- a/internal/skills/recipes/house-style/housestyle/lint.py +++ b/internal/skills/recipes/house-style/housestyle/lint.py @@ -102,7 +102,11 @@ def lint_docx(path: str) -> list[dict]: def lint_pptx(path: str) -> list[dict]: - prs = Presentation(path) + try: + prs = Presentation(path) + except Exception as exc: + return [_issue(path, "error", "structure-broken", + f"无法按 PPTX 解析({type(exc).__name__}: {exc})——疑似手拼 OOXML 或文件损坏,必须用 housestyle kit 重建")] issues = [] templated = "housestyle-template" in (prs.core_properties.subject or "") @@ -111,23 +115,50 @@ def lint_pptx(path: str) -> list[dict]: severity = "warning" issues.append(_issue(path, severity, rule, message, where)) - if prs.slide_width and prs.slide_height: - ratio = prs.slide_width / prs.slide_height - if abs(ratio - 16 / 9) > 0.05: - add("slide-size", "error", f"不是 16:9(宽高比 {ratio:.3f})") - for index, slide in enumerate(prs.slides, start=1): - texts = [] - for shape in slide.shapes: - if not shape.has_text_frame: - continue - body = shape.text_frame.text.strip() - if body: - texts.append(body) - count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip()) - if count > 6: - add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}") - if not texts: - add("empty-title", "error", "空白页", f"slide {index}") + # 结构底线(OPC 合法性,不随模板降级):母版、主题部件必须存在,每张 + # slide 的版式→母版链可达。python-pptx 对缺失母版完全容忍,但 Office + # 会按残缺包拒绝打开;docx/xlsx 没有这类缺口(打开成功即要求核心部件存在)。 + try: + masters = list(prs.slide_masters) + except Exception as exc: + add("structure-broken", "error", f"母版列表不可读({type(exc).__name__}: {exc})") + masters = [] + if not masters: + add("structure-broken", "error", "没有幻灯片母版——缺母版/主题的包 Office 会判为损坏") + for master in masters: + try: + reltypes = [rel.reltype for rel in master.part.rels.values()] + except Exception as exc: + add("structure-broken", "error", f"母版关系不可读({type(exc).__name__}: {exc})") + continue + if not any(t.endswith("/theme") for t in reltypes): + add("structure-broken", "error", "母版没有关联主题(theme)") + + try: + slides = list(prs.slides) + except Exception as exc: + add("structure-broken", "error", f"幻灯片列表不可读({type(exc).__name__}: {exc})") + return issues + for index, slide in enumerate(slides, start=1): + try: + slide.slide_layout.slide_master + except Exception as exc: + add("structure-broken", "error", f"版式/母版链断裂({type(exc).__name__}: {exc})", f"slide {index}") + try: + texts = [] + for shape in slide.shapes: + if not shape.has_text_frame: + continue + body = shape.text_frame.text.strip() + if body: + texts.append(body) + count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip()) + if count > 6: + add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}") + if not texts: + add("empty-title", "error", "空白页", f"slide {index}") + except Exception as exc: + add("structure-broken", "error", f"幻灯片正文不可读({type(exc).__name__}: {exc})", f"slide {index}") return issues diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py index 49fd0b3..0afba31 100644 --- a/internal/skills/recipes/house-style/tests/test_housestyle.py +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -212,3 +212,53 @@ class BuildTests(unittest.TestCase): # PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。 if r.returncode not in (0, 1): self.fail(r.stderr[:200]) + + +class BrokenPptxTests(unittest.TestCase): + """手拼 OOXML 的结构底线:缺母版/主题/版式链必须报 structure-broken。""" + + @staticmethod + def _broken_pptx(path): + import zipfile + ct = ('' + '' + '' + '' + '') + p = "http://schemas.openxmlformats.org/presentationml/2006/main" + parts = { + "[Content_Types].xml": ct, + "_rels/.rels": ('' + ''), + "ppt/presentation.xml": f'', + "ppt/_rels/presentation.xml.rels": ('' + ''), + "ppt/slides/slide1.xml": f'', + "ppt/slideLayouts/slideLayout1.xml": f'', + } + with zipfile.ZipFile(path, "w") as z: + for name, body in parts.items(): + z.writestr(name, body) + + def test_masterless_package_reports_structure_broken(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "broken.pptx") + self._broken_pptx(path) + rules = {item["rule"] for item in _errors(lint_pptx(path))} + self.assertIn("structure-broken", rules) + + def test_kit_output_has_no_structure_errors(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "ok.pptx") + prs = pptx_kit.new_presentation(theme="modern", title="自检") + pptx_kit.add_title_slide(prs, "标题", "副标题") + pptx_kit.save(prs, path) + rules = {item["rule"] for item in _errors(lint_pptx(path))} + self.assertNotIn("structure-broken", rules) + + def test_unopenable_file_reports_structure_broken(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "fake.pptx") + Path(path).write_text("这不是 zip", encoding="utf-8") + issues = lint_pptx(path) + self.assertEqual([item["rule"] for item in issues], ["structure-broken"]) diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 5a42cf0..8823499 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -131,6 +131,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) if err != nil { return "", err } + if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil { + return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err) + } name := filepath.Base(abs) mime := mimeOfExt(name) art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b) diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go new file mode 100644 index 0000000..d7f5f1c --- /dev/null +++ b/internal/tools/ooxmlgate.go @@ -0,0 +1,269 @@ +// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。 +// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出缺母版/主题/文档 +// 属性的残缺包——正文抽取照常成功,直到 Office 按 OPC 完整性校验打开时才报 +// 损坏。这里按必备部件与 slide→layout→master→theme 引用链在登记前拦截, +// 失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁) +// 不在本层:本层只保证「Office 打得开」这条底线。 +package tools + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "path" + "path/filepath" + "strings" +) + +// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开 +// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。 +func ooxmlStructuralCheck(name string, content []byte) error { + switch strings.ToLower(filepath.Ext(name)) { + case ".docx": + return ooxmlCheckParts(content, "word/document.xml") + case ".xlsx": + return ooxmlCheckParts(content, "xl/workbook.xml") + case ".pptx": + return ooxmlCheckPptx(content) + default: + return nil + } +} + +// ooxmlCheckParts 校验容器与必备正文部件存在且为合法 XML(docx/xlsx 底线)。 +func ooxmlCheckParts(content []byte, parts ...string) error { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err) + } + if err := ooxmlWellFormed(zr, "[Content_Types].xml"); err != nil { + return err + } + for _, part := range parts { + if err := ooxmlWellFormed(zr, part); err != nil { + return err + } + } + return nil +} + +// ooxmlCheckPptx 校验演示稿的完整引用链:presentation 声明母版列表, +// 每张 slide 可达 slideLayout,版式可达 slideMaster,母版可达 theme。 +func ooxmlCheckPptx(content []byte) error { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err) + } + for _, part := range []string{"[Content_Types].xml", "ppt/presentation.xml"} { + if err := ooxmlWellFormed(zr, part); err != nil { + return err + } + } + if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil { + return fmt.Errorf("presentation.xml 缺少 (幻灯片母版列表)——母版与主题是必备结构") + } + presRels, err := ooxmlRels(zr, "ppt/presentation.xml") + if err != nil { + return err + } + var slides, masters []string + for _, rel := range presRels { + switch { + case strings.HasSuffix(rel.typ, "/slide"): + slides = append(slides, ooxmlResolve("ppt/presentation.xml", rel.target)) + case strings.HasSuffix(rel.typ, "/slideMaster"): + masters = append(masters, ooxmlResolve("ppt/presentation.xml", rel.target)) + } + } + if len(masters) == 0 { + return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构") + } + for _, master := range masters { + if err := ooxmlMasterChain(zr, master); err != nil { + return err + } + } + if len(slides) == 0 { + return fmt.Errorf("presentation 未引用任何幻灯片") + } + seenLayouts := map[string]bool{} + for _, slide := range slides { + if err := ooxmlWellFormed(zr, slide); err != nil { + return err + } + rels, err := ooxmlRels(zr, slide) + if err != nil { + return fmt.Errorf("幻灯片 %s:%v", slide, err) + } + layout := "" + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/slideLayout") { + layout = ooxmlResolve(slide, rel.target) + break + } + } + if layout == "" { + return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide) + } + if seenLayouts[layout] { + continue + } + seenLayouts[layout] = true + if err := ooxmlWellFormed(zr, layout); err != nil { + return err + } + if err := ooxmlLayoutMaster(zr, layout); err != nil { + return err + } + } + return nil +} + +// ooxmlMasterChain 校验母版存在且其关系表引用了存在的 theme。 +func ooxmlMasterChain(zr *zip.Reader, master string) error { + if err := ooxmlWellFormed(zr, master); err != nil { + return err + } + rels, err := ooxmlRels(zr, master) + if err != nil { + return err + } + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/theme") { + return ooxmlWellFormed(zr, ooxmlResolve(master, rel.target)) + } + } + return fmt.Errorf("母版 %s 没有指向 theme 的关系", master) +} + +// ooxmlLayoutMaster 校验版式的关系表引用了存在的 slideMaster。 +func ooxmlLayoutMaster(zr *zip.Reader, layout string) error { + rels, err := ooxmlRels(zr, layout) + if err != nil { + return err + } + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/slideMaster") { + return ooxmlWellFormed(zr, ooxmlResolve(layout, rel.target)) + } + } + return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout) +} + +// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。 +func ooxmlWellFormed(zr *zip.Reader, part string) error { + f, err := ooxmlPart(zr, part) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("部件 %s 无法读取(%v)", part, err) + } + defer rc.Close() + dec := xml.NewDecoder(rc) + for { + _, err := dec.Token() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err) + } + } +} + +// ooxmlRequireElement 确认部件内出现指定局部名的元素(不校验位置与属性)。 +func ooxmlRequireElement(zr *zip.Reader, part, local string) error { + f, err := ooxmlPart(zr, part) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("部件 %s 无法读取(%v)", part, err) + } + defer rc.Close() + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err) + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { + return nil + } + } + return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local) +} + +// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。 +func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) { + for _, f := range zr.File { + if f.Name == name { + return f, nil + } + } + return nil, fmt.Errorf("缺少必备部件 %s", name) +} + +// ooxmlRel 是 rels 部件里的一条关系:类型 URI 与目标(原样保留相对形式)。 +type ooxmlRel struct { + typ string + target string +} + +// ooxmlRels 解析 part 的关系表(/_rels/.rels)。 +func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) { + dir, base := path.Split(part) + relsPart := path.Join(dir, "_rels", base+".rels") + f, err := ooxmlPart(zr, relsPart) + if err != nil { + return nil, fmt.Errorf("%s 的关系表缺失(%v)", part, err) + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%s 无法读取(%v)", relsPart, err) + } + defer rc.Close() + var rels []ooxmlRel + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("%s 不是合法 XML(%v)", relsPart, err) + } + se, ok := tok.(xml.StartElement) + if !ok || se.Name.Local != "Relationship" { + continue + } + var rel ooxmlRel + for _, attr := range se.Attr { + switch attr.Name.Local { + case "Type": + rel.typ = attr.Value + case "Target": + rel.target = attr.Value + } + } + rels = append(rels, rel) + } + return rels, nil +} + +// ooxmlResolve 把关系目标解析为包内部件路径:绝对目标去掉前导 /, +// 相对目标基于源部件目录解析(path.Join 自带 .. 清理)。 +func ooxmlResolve(srcPart, target string) string { + if strings.HasPrefix(target, "/") { + return strings.TrimPrefix(target, "/") + } + return path.Join(path.Dir(srcPart), target) +} diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go new file mode 100644 index 0000000..9ee2957 --- /dev/null +++ b/internal/tools/ooxmlgate_test.go @@ -0,0 +1,221 @@ +package tools + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// zipOf 按序打包字符串部件(保持插入序便于断言)。 +func zipOf(t *testing.T, files map[string]string) []byte { + t.Helper() + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + for name, body := range files { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +const ( + gateCTXML = `` + gatePres = `` + gateSlide = `` + gateLayout = `` + gateMaster = `` + gateTheme = `` +) + +func relXML(rels ...string) string { + return `` + + strings.Join(rels, "") + `` +} + +const ( + relSlide = `` + relMaster = `` + relLayout = `` + relToMaster = `` + relTheme = `` +) + +// gateValidPptx 是结构合法的最小演示稿:引用链 slide→layout→master→theme 齐备。 +func gateValidPptx(t *testing.T) []byte { + t.Helper() + return zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "_rels/.rels": relXML(), + "ppt/presentation.xml": gatePres, + "ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster), + "ppt/slides/slide1.xml": gateSlide, + "ppt/slides/_rels/slide1.xml.rels": relXML(relLayout), + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster), + "ppt/slideMasters/slideMaster1.xml": gateMaster, + "ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme), + "ppt/theme/theme1.xml": gateTheme, + }) +} + +// gateBrokenPptx 复刻事故包:只有 presentation + slides + 幽灵 layout, +// 无母版/主题/docProps,presentation 缺 sldMasterIdLst,slide 无关系表。 +func gateBrokenPptx(t *testing.T) []byte { + t.Helper() + return zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "_rels/.rels": relXML(), + "ppt/presentation.xml": ``, + "ppt/_rels/presentation.xml.rels": relXML(relSlide), + "ppt/slides/slide1.xml": gateSlide, + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + }) +} + +func TestOoxmlGateValidPptx(t *testing.T) { + if err := ooxmlStructuralCheck("交付物.pptx", gateValidPptx(t)); err != nil { + t.Fatalf("valid pptx must pass: %v", err) + } +} + +func TestOoxmlGateBrokenPptxIncident(t *testing.T) { + err := ooxmlStructuralCheck("机械臂腹部超声设计方案.pptx", gateBrokenPptx(t)) + if err == nil { + t.Fatal("incident-shaped pptx must be rejected") + } + for _, want := range []string{"母版", "sldMasterIdLst"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q must mention %q", err, want) + } + } +} + +func TestOoxmlGatePptxChainBreaks(t *testing.T) { + base := func(t *testing.T) map[string]string { + t.Helper() + return map[string]string{ + "[Content_Types].xml": gateCTXML, + "_rels/.rels": relXML(), + "ppt/presentation.xml": gatePres, + "ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster), + "ppt/slides/slide1.xml": gateSlide, + "ppt/slides/_rels/slide1.xml.rels": relXML(relLayout), + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster), + "ppt/slideMasters/slideMaster1.xml": gateMaster, + "ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme), + "ppt/theme/theme1.xml": gateTheme, + } + } + cases := []struct { + name string + mut func(m map[string]string) + want string + }{ + {"slide-no-rels", func(m map[string]string) { + delete(m, "ppt/slides/_rels/slide1.xml.rels") + }, "关系表缺失"}, + {"slide-no-layout-rel", func(m map[string]string) { + m["ppt/slides/_rels/slide1.xml.rels"] = relXML() + }, "没有指向 slideLayout"}, + {"layout-missing", func(m map[string]string) { + delete(m, "ppt/slideLayouts/slideLayout1.xml") + }, "缺少必备部件"}, + {"layout-no-master-rel", func(m map[string]string) { + m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML() + }, "没有指向 slideMaster"}, + {"master-no-theme-rel", func(m map[string]string) { + m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML() + }, "没有指向 theme"}, + {"theme-missing", func(m map[string]string) { + delete(m, "ppt/theme/theme1.xml") + }, "缺少必备部件"}, + {"pres-no-master-idlst", func(m map[string]string) { + m["ppt/presentation.xml"] = strings.Replace(gatePres, "", "", 1) + }, "sldMasterIdLst"}, + {"pres-no-slide-rels", func(m map[string]string) { + m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster) + }, "未引用任何幻灯片"}, + {"xml-malformed", func(m map[string]string) { + m["ppt/slides/slide1.xml"] = "" + }, "不是合法 XML"}, + } + for _, tc := range cases { + m := base(t) + tc.mut(m) + err := ooxmlStructuralCheck("deck.pptx", zipOf(t, m)) + if err == nil { + t.Fatalf("%s: must be rejected", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want) + } + } +} + +func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) { + validDocx := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "word/document.xml": ``, + }) + if err := ooxmlStructuralCheck("方案.docx", validDocx); err != nil { + t.Fatalf("valid docx must pass: %v", err) + } + if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil { + t.Fatal("docx without document.xml must be rejected") + } + validXlsx := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "xl/workbook.xml": ``, + }) + if err := ooxmlStructuralCheck("数据.xlsx", validXlsx); err != nil { + t.Fatalf("valid xlsx must pass: %v", err) + } + if err := ooxmlStructuralCheck("备注.md", []byte("# 标题\n正文")); err != nil { + t.Fatalf("non-office files must pass through: %v", err) + } + if err := ooxmlStructuralCheck("坏.pptx", []byte("这是一段文本,不是 zip")); err == nil { + t.Fatal("text masquerading as pptx must be rejected") + } +} + +// 登记口集成:残缺包在 register_artifact 处被拒并给出重建指引;合法包照常登记。 +func TestRegisterArtifactStructuralGate(t *testing.T) { + r := NewRegistry() + if err := RegisterExecTools(r); err != nil { + t.Fatal(err) + } + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + sandbox := filepath.Join(root, "o1", "c1") + if err := os.MkdirAll(sandbox, 0o755); err != nil { + t.Fatal(err) + } + broken := filepath.Join(sandbox, "坏.pptx") + if err := os.WriteFile(broken, gateBrokenPptx(t), 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`) + if res.Status != "error" || !strings.Contains(res.Output, "结构自检未通过") || !strings.Contains(res.Output, "housestyle") { + t.Fatalf("broken pptx must be refused with rebuild hint: %s %s", res.Status, res.Output) + } + good := filepath.Join(sandbox, "好.pptx") + if err := os.WriteFile(good, gateValidPptx(t), 0o644); err != nil { + t.Fatal(err) + } + res = interpCall(t, r, rc, "register_artifact", `{"path":"好.pptx"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") { + t.Fatalf("valid pptx must register: %s %s", res.Status, res.Output) + } +}