Merge remote-tracking branch 'origin/main' into feature/iam-u4-role-authority
# Conflicts: # pkg/extension/extension.go # pkg/extension/surface.txt
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
"agent-system-backend/pkg/identity"
|
||||
)
|
||||
|
||||
@@ -73,7 +74,19 @@ func main() {
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||
extension.RegisterCLIFlags(fs)
|
||||
fs.Parse(os.Args[1:])
|
||||
handled, hookErr := extension.RunCLIHooks(extension.Options{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend,
|
||||
})
|
||||
if hookErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", hookErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
cliSet := visited(fs)
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
@@ -94,11 +107,36 @@ func main() {
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
pluginDefaults, merr := extension.MergeHostOptions(extension.Options{})
|
||||
if merr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", merr)
|
||||
os.Exit(1)
|
||||
}
|
||||
disableExec := resolved.DisableExecTools
|
||||
if !cliSet["disableExecTools"] {
|
||||
disableExec = disableExec || pluginDefaults.DisableExecTools
|
||||
}
|
||||
execBackendResolved := resolved.ExecBackend
|
||||
if !cliSet["execBackend"] && execBackendResolved == "" {
|
||||
execBackendResolved = pluginDefaults.ExecBackend
|
||||
}
|
||||
extOpt := extension.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir,
|
||||
DisableUploadIndexing: pluginDefaults.DisableUploadIndexing,
|
||||
DisableConvCardMirror: pluginDefaults.DisableConvCardMirror,
|
||||
DisableExecTools: disableExec, ExecBackend: execBackendResolved,
|
||||
}
|
||||
if err := extension.PrepareCompiled(&extOpt); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(app.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
DisableUploadIndexing: extOpt.DisableUploadIndexing,
|
||||
DisableConvCardMirror: extOpt.DisableConvCardMirror,
|
||||
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
ExecWorkspace: resolved.ExecWorkspace,
|
||||
Tier: resolved.Tier,
|
||||
PGPool: store.PoolOptions{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: tool.run_python
|
||||
layer: tool
|
||||
version: 2
|
||||
version: 3
|
||||
title: run_python 工具描述
|
||||
description: Python 执行原语的模型侧说明文案
|
||||
---
|
||||
执行完整 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`。
|
||||
执行完整 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`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。kit 路线执行失败(报错、超时、后台任务溢出)时,修复后仍走 kit 路线重试——任何失败都不构成退回手工拼 OOXML 的理由。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
|
||||
@@ -70,8 +70,24 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) {
|
||||
t.Fatalf("child page: %d %v", code, childPage)
|
||||
}
|
||||
childRows := childPage["events"].([]any)
|
||||
if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) {
|
||||
t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows)
|
||||
var childTypes []string
|
||||
for _, row := range childRows {
|
||||
ev := row.(map[string]any)
|
||||
typ, _ := ev["type"].(string)
|
||||
if typ == string(core.EvToolOutput) {
|
||||
payload, _ := ev["payload"].(map[string]any)
|
||||
if payload["text"] == "child raw output" {
|
||||
t.Fatalf("raw child output leaked: %v", childRows)
|
||||
}
|
||||
}
|
||||
if typ == string(core.EvAgentProgress) || typ == string(core.EvAgentResult) {
|
||||
childTypes = append(childTypes, typ)
|
||||
}
|
||||
}
|
||||
// 父运行的终态事件可能在 waitRunTerminal 之后才落库,序号会落在这次追加的
|
||||
// 工具输出和子运行事件之间。这里只要求子运行进度与结果按序保留。
|
||||
if len(childTypes) != 2 || childTypes[0] != string(core.EvAgentProgress) || childTypes[1] != string(core.EvAgentResult) {
|
||||
t.Fatalf("child progress/result not retained: types=%v rows=%v", childTypes, childRows)
|
||||
}
|
||||
code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil)
|
||||
if code != http.StatusNotFound {
|
||||
|
||||
@@ -2066,10 +2066,15 @@ func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
|
||||
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
|
||||
compiledIDs := make([]string, 0, len(s.CompiledPlugins))
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
compiledIDs = append(compiledIDs, cp.ID)
|
||||
}
|
||||
reg := map[string]any{
|
||||
"tools": s.visibleTools(s.owner(r)),
|
||||
"parsers": ingest.ParsersDescription(),
|
||||
"panels": flattenPanels(s.plugins.List()),
|
||||
"tools": s.visibleTools(s.owner(r)),
|
||||
"parsers": ingest.ParsersDescription(),
|
||||
"panels": flattenPanels(s.plugins.List()),
|
||||
"compiled": compiledIDs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reg)
|
||||
}
|
||||
|
||||
+343
-33
@@ -1,8 +1,10 @@
|
||||
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
|
||||
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出缺母版/主题/文档
|
||||
// 属性的残缺包——正文抽取照常成功,直到 Office 按 OPC 完整性校验打开时才报
|
||||
// 损坏。这里按必备部件与 slide→layout→master→theme 引用链在登记前拦截,
|
||||
// 失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
|
||||
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的
|
||||
// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件
|
||||
// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、
|
||||
// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。
|
||||
// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前
|
||||
// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
|
||||
// 不在本层:本层只保证「Office 打得开」这条底线。
|
||||
package tools
|
||||
|
||||
@@ -17,6 +19,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
ctPresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
|
||||
ctSlide = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
|
||||
ctSlideLayout = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
|
||||
ctSlideMaster = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
|
||||
ctTheme = "application/vnd.openxmlformats-officedocument.theme+xml"
|
||||
)
|
||||
|
||||
// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开
|
||||
// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。
|
||||
func ooxmlStructuralCheck(name string, content []byte) error {
|
||||
@@ -32,7 +44,8 @@ func ooxmlStructuralCheck(name string, content []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ooxmlCheckParts 校验容器与必备正文部件存在且为合法 XML(docx/xlsx 底线)。
|
||||
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
|
||||
// (docx/xlsx 底线)。
|
||||
func ooxmlCheckParts(content []byte, parts ...string) error {
|
||||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
@@ -46,11 +59,11 @@ func ooxmlCheckParts(content []byte, parts ...string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ooxmlNamespaceStrict(zr)
|
||||
}
|
||||
|
||||
// ooxmlCheckPptx 校验演示稿的完整引用链:presentation 声明母版列表,
|
||||
// 每张 slide 可达 slideLayout,版式可达 slideMaster,母版可达 theme。
|
||||
// ooxmlCheckPptx 校验演示稿:引用链完整(slide→layout→master→theme)、
|
||||
// sldIdLst/sldMasterIdLst 与关系表一致、[Content_Types] 覆盖全部结构部件。
|
||||
func ooxmlCheckPptx(content []byte) error {
|
||||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
if err != nil {
|
||||
@@ -64,32 +77,58 @@ func ooxmlCheckPptx(content []byte) error {
|
||||
if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil {
|
||||
return fmt.Errorf("presentation.xml 缺少 <sldMasterIdLst>(幻灯片母版列表)——母版与主题是必备结构")
|
||||
}
|
||||
// 严格命名空间先行:后续的 r:id 属性解析依赖前缀正确声明。
|
||||
if err := ooxmlNamespaceStrict(zr); err != nil {
|
||||
return err
|
||||
}
|
||||
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))
|
||||
}
|
||||
// sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。
|
||||
masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(masters) == 0 {
|
||||
slideIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldIdLst", "sldId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relsByType := func(suffix string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, rel := range presRels {
|
||||
if strings.HasSuffix(rel.typ, suffix) {
|
||||
out[rel.id] = ooxmlResolve("ppt/presentation.xml", rel.target)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
masterTargets, err := ooxmlConsistent(masterIDs, relsByType("/slideMaster"), "sldMasterIdLst", "slideMaster")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slideTargets, err := ooxmlConsistent(slideIDs, relsByType("/slide"), "sldIdLst", "slide")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(masterTargets) == 0 {
|
||||
return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构")
|
||||
}
|
||||
for _, master := range masters {
|
||||
if err := ooxmlMasterChain(zr, master); err != nil {
|
||||
if len(slideTargets) == 0 {
|
||||
return fmt.Errorf("presentation 未引用任何幻灯片")
|
||||
}
|
||||
themes := map[string]bool{}
|
||||
for _, master := range masterTargets {
|
||||
if err := ooxmlMasterChain(zr, master, themes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(slides) == 0 {
|
||||
return fmt.Errorf("presentation 未引用任何幻灯片")
|
||||
masterSet := map[string]bool{}
|
||||
for _, m := range masterTargets {
|
||||
masterSet[m] = true
|
||||
}
|
||||
seenLayouts := map[string]bool{}
|
||||
for _, slide := range slides {
|
||||
layouts := map[string]bool{}
|
||||
for _, slide := range slideTargets {
|
||||
if err := ooxmlWellFormed(zr, slide); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,22 +146,50 @@ func ooxmlCheckPptx(content []byte) error {
|
||||
if layout == "" {
|
||||
return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide)
|
||||
}
|
||||
if seenLayouts[layout] {
|
||||
if layouts[layout] {
|
||||
continue
|
||||
}
|
||||
seenLayouts[layout] = true
|
||||
layouts[layout] = true
|
||||
if err := ooxmlWellFormed(zr, layout); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ooxmlLayoutMaster(zr, layout); err != nil {
|
||||
if err := ooxmlLayoutMaster(zr, layout, masterSet); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// [Content_Types].xml 必须对每个结构部件给出 Override;缺了会被 Office
|
||||
// 当成未知类型拒开(真实事故:只写了 slide1 的 Override)。
|
||||
overrides, err := ooxmlCTOverrides(zr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := map[string]string{"ppt/presentation.xml": ctPresentation}
|
||||
for _, t := range masterTargets {
|
||||
want[t] = ctSlideMaster
|
||||
}
|
||||
for _, t := range slideTargets {
|
||||
want[t] = ctSlide
|
||||
}
|
||||
for t := range layouts {
|
||||
want[t] = ctSlideLayout
|
||||
}
|
||||
for t := range themes {
|
||||
want[t] = ctTheme
|
||||
}
|
||||
for part, ct := range want {
|
||||
got, ok := overrides["/"+part]
|
||||
if !ok {
|
||||
return fmt.Errorf("[Content_Types].xml 缺少 %s 的 Override(%s)——手拼包常见缺陷,请用 housestyle kit 重建", part, ct)
|
||||
}
|
||||
if got != ct {
|
||||
return fmt.Errorf("[Content_Types].xml 中 %s 的内容类型不符:got %s want %s", part, got, ct)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ooxmlMasterChain 校验母版存在且其关系表引用了存在的 theme。
|
||||
func ooxmlMasterChain(zr *zip.Reader, master string) error {
|
||||
// ooxmlMasterChain 校验母版存在、声明版式列表且与关系表一致、引用存在的 theme。
|
||||
func ooxmlMasterChain(zr *zip.Reader, master string, themes map[string]bool) error {
|
||||
if err := ooxmlWellFormed(zr, master); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,28 +197,258 @@ func ooxmlMasterChain(zr *zip.Reader, master string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ooxmlRequireElement(zr, master, "sldLayoutIdLst"); err != nil {
|
||||
return fmt.Errorf("母版 %s 缺少 <sldLayoutIdLst>(版式列表)——母版必须声明至少一个版式", master)
|
||||
}
|
||||
layoutIDs, err := ooxmlIdListOf(zr, master, "sldLayoutIdLst", "sldLayoutId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layoutTargets := map[string]string{}
|
||||
for _, rel := range rels {
|
||||
if strings.HasSuffix(rel.typ, "/slideLayout") {
|
||||
layoutTargets[rel.id] = ooxmlResolve(master, rel.target)
|
||||
}
|
||||
}
|
||||
resolved, err := ooxmlConsistent(layoutIDs, layoutTargets, "sldLayoutIdLst", "slideLayout")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range resolved {
|
||||
if err := ooxmlWellFormed(zr, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rel := range rels {
|
||||
if strings.HasSuffix(rel.typ, "/theme") {
|
||||
return ooxmlWellFormed(zr, ooxmlResolve(master, rel.target))
|
||||
theme := ooxmlResolve(master, rel.target)
|
||||
if err := ooxmlWellFormed(zr, theme); err != nil {
|
||||
return err
|
||||
}
|
||||
themes[theme] = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("母版 %s 没有指向 theme 的关系", master)
|
||||
}
|
||||
|
||||
// ooxmlLayoutMaster 校验版式的关系表引用了存在的 slideMaster。
|
||||
func ooxmlLayoutMaster(zr *zip.Reader, layout string) error {
|
||||
// ooxmlLayoutMaster 校验版式的关系表引用了存在的、且被 presentation 列出的
|
||||
// slideMaster。
|
||||
func ooxmlLayoutMaster(zr *zip.Reader, layout string, masters map[string]bool) 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))
|
||||
master := ooxmlResolve(layout, rel.target)
|
||||
if !masters[master] {
|
||||
return fmt.Errorf("版式 %s 指向的母版 %s 未在 presentation 的 sldMasterIdLst 中声明", layout, master)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout)
|
||||
}
|
||||
|
||||
// ooxmlIdListOf 提取 part 中 <listLocal> 列表内每个 <itemLocal> 的 r:id 属性。
|
||||
func ooxmlIdListOf(zr *zip.Reader, part, listLocal, itemLocal string) ([]string, error) {
|
||||
f, err := ooxmlPart(zr, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
dec := xml.NewDecoder(rc)
|
||||
var ids []string
|
||||
inList := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||||
}
|
||||
switch se := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
|
||||
inList = true
|
||||
continue
|
||||
}
|
||||
if inList && se.Name.Space == nsPresentationML && se.Name.Local == itemLocal {
|
||||
if rid, ok := ooxmlAttrNS(se, nsOfficeDocRel, "id"); ok {
|
||||
ids = append(ids, rid)
|
||||
} else {
|
||||
return nil, fmt.Errorf("%s 的 <%s> 缺少 r:id 属性——引用链断裂,请用 housestyle kit 重建", listLocal, itemLocal)
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if inList && se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ooxmlConsistent 校验 ID 清单与关系表双向一致:清单里每个 r:id 都要命中
|
||||
// 同类型关系,关系表也不得多出或缺失。返回按清单顺序解析出的目标部件。
|
||||
func ooxmlConsistent(ids []string, targets map[string]string, listName, what string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("%s 重复引用 %s", listName, id)
|
||||
}
|
||||
seen[id] = true
|
||||
target, ok := targets[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s 引用的 %s 在关系表中不存在(rId=%s 断链)——请用 housestyle kit 重建", listName, what, id)
|
||||
}
|
||||
out = append(out, target)
|
||||
}
|
||||
if len(targets) != len(ids) {
|
||||
return nil, fmt.Errorf("%s 与关系表数量不一致(清单 %d 条,关系表 %d 条)——存在未引用或缺失的 %s 关系,请用 housestyle kit 重建", listName, len(ids), len(targets), what)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ooxmlNamespaceStrict 扫描包内全部 XML 部件:元素或属性使用了未声明的
|
||||
// 命名空间前缀即报错。判定基于声明集合而非 URI 形态(Go 解码器对未声明
|
||||
// 前缀保留原始前缀名作为 Space;已声明则解析为完整 URI,urn: 等 scheme
|
||||
// 同样正确解析),避免对非 http URI 的误伤。
|
||||
func ooxmlNamespaceStrict(zr *zip.Reader) error {
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasSuffix(f.Name, ".xml") && !strings.HasSuffix(f.Name, ".rels") {
|
||||
continue
|
||||
}
|
||||
declared, err := ooxmlDeclaredNamespaces(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
|
||||
}
|
||||
dec := xml.NewDecoder(rc)
|
||||
var scanErr error
|
||||
scan:
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
scanErr = fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
|
||||
break
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sp := ooxmlUnboundPrefix(se.Name.Space, declared); sp != "" {
|
||||
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(元素 <%s:%s>)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, se.Name.Local, sp)
|
||||
break
|
||||
}
|
||||
for _, a := range se.Attr {
|
||||
if sp := ooxmlUnboundPrefix(a.Name.Space, declared); sp != "" {
|
||||
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(属性 %s:%s)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, a.Name.Local, sp)
|
||||
break scan
|
||||
}
|
||||
}
|
||||
}
|
||||
rc.Close()
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ooxmlDeclaredNamespaces 收集部件内全部 xmlns 声明(含默认命名空间与任意
|
||||
// 位置的限定声明)。前缀 xmlns 为保留名:Go 把 xmlns:r 声明报为
|
||||
// Space="xmlns"、Local="r";默认声明 xmlns="…" 报为 Space=""、Local="xmlns"。
|
||||
func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
out := map[string]bool{"xml": true, "xmlns": true}
|
||||
dec := xml.NewDecoder(rc)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, a := range se.Attr {
|
||||
switch {
|
||||
case a.Name.Space == "xmlns":
|
||||
out[a.Value] = true
|
||||
case a.Name.Space == "" && a.Name.Local == "xmlns":
|
||||
out[a.Value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ooxmlUnboundPrefix 返回未绑定的前缀名;已声明、无前缀与内建 xml 前缀返回空串。
|
||||
func ooxmlUnboundPrefix(space string, declared map[string]bool) string {
|
||||
if space == "" || declared[space] {
|
||||
return ""
|
||||
}
|
||||
return space
|
||||
}
|
||||
|
||||
// ooxmlCTOverrides 解析 [Content_Types].xml 的 Override 清单(部件路径 → 内容类型)。
|
||||
func ooxmlCTOverrides(zr *zip.Reader) (map[string]string, error) {
|
||||
f, err := ooxmlPart(zr, "[Content_Types].xml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[Content_Types].xml 无法读取(%v)", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
out := map[string]string{}
|
||||
dec := xml.NewDecoder(rc)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[Content_Types].xml 不是合法 XML(%v)", err)
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok || se.Name.Local != "Override" {
|
||||
continue
|
||||
}
|
||||
partName, contentType := "", ""
|
||||
for _, a := range se.Attr {
|
||||
switch a.Name.Local {
|
||||
case "PartName":
|
||||
partName = a.Value
|
||||
case "ContentType":
|
||||
contentType = a.Value
|
||||
}
|
||||
}
|
||||
out[partName] = contentType
|
||||
}
|
||||
}
|
||||
|
||||
// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。
|
||||
func ooxmlWellFormed(zr *zip.Reader, part string) error {
|
||||
f, err := ooxmlPart(zr, part)
|
||||
@@ -202,6 +499,16 @@ func ooxmlRequireElement(zr *zip.Reader, part, local string) error {
|
||||
return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local)
|
||||
}
|
||||
|
||||
// ooxmlAttrNS 取指定命名空间下的属性值。
|
||||
func ooxmlAttrNS(se xml.StartElement, space, local string) (string, bool) {
|
||||
for _, a := range se.Attr {
|
||||
if a.Name.Space == space && a.Name.Local == local {
|
||||
return a.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。
|
||||
func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
|
||||
for _, f := range zr.File {
|
||||
@@ -212,8 +519,9 @@ func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
|
||||
return nil, fmt.Errorf("缺少必备部件 %s", name)
|
||||
}
|
||||
|
||||
// ooxmlRel 是 rels 部件里的一条关系:类型 URI 与目标(原样保留相对形式)。
|
||||
// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。
|
||||
type ooxmlRel struct {
|
||||
id string
|
||||
typ string
|
||||
target string
|
||||
}
|
||||
@@ -248,6 +556,8 @@ func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) {
|
||||
var rel ooxmlRel
|
||||
for _, attr := range se.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "Id":
|
||||
rel.id = attr.Value
|
||||
case "Type":
|
||||
rel.typ = attr.Value
|
||||
case "Target":
|
||||
|
||||
@@ -30,11 +30,10 @@ func zipOf(t *testing.T, files map[string]string) []byte {
|
||||
}
|
||||
|
||||
const (
|
||||
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`
|
||||
gatePres = `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`
|
||||
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>`
|
||||
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sld>`
|
||||
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sldLayout>`
|
||||
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
|
||||
gateTheme = `<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>`
|
||||
)
|
||||
|
||||
@@ -44,42 +43,96 @@ func relXML(rels ...string) string {
|
||||
}
|
||||
|
||||
const (
|
||||
relSlide = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
|
||||
relMaster = `<Relationship Id="r2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
|
||||
relLayout = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
|
||||
relToMaster = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
|
||||
relTheme = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
|
||||
relSlide = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
|
||||
relMaster = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
|
||||
relLayout = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
|
||||
relToMaster = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
|
||||
relTheme = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
|
||||
)
|
||||
|
||||
// gateValidPptx 是结构合法的最小演示稿:引用链 slide→layout→master→theme 齐备。
|
||||
// gateCT 内容类型表:传入要 Override 的结构部件(缺了的用例自己构造残缺形态)。
|
||||
func gateCT(parts ...string) string {
|
||||
cts := map[string]string{
|
||||
"ppt/presentation.xml": ctPresentation,
|
||||
"ppt/slides/slide1.xml": ctSlide,
|
||||
"ppt/slides/slide2.xml": ctSlide,
|
||||
"ppt/slides/slide3.xml": ctSlide,
|
||||
"ppt/slideLayouts/slideLayout1.xml": ctSlideLayout,
|
||||
"ppt/slideMasters/slideMaster1.xml": ctSlideMaster,
|
||||
"ppt/theme/theme1.xml": ctTheme,
|
||||
}
|
||||
out := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`
|
||||
for _, p := range parts {
|
||||
out += `<Override PartName="/` + p + `" ContentType="` + cts[p] + `"/>`
|
||||
}
|
||||
return out + `</Types>`
|
||||
}
|
||||
|
||||
var gateAllParts = []string{
|
||||
"ppt/presentation.xml", "ppt/slides/slide1.xml",
|
||||
"ppt/slideLayouts/slideLayout1.xml", "ppt/slideMasters/slideMaster1.xml", "ppt/theme/theme1.xml",
|
||||
}
|
||||
|
||||
// gateValidPptx 是结构合法的最小演示稿:r:id 引用、ID 清单与关系表一致、
|
||||
// 内容类型全量覆盖——与 python-pptx 产物的关键形态一致。
|
||||
func gateValidPptx(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"[Content_Types].xml": gateCT(gateAllParts...),
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": gatePres,
|
||||
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`,
|
||||
"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/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
|
||||
"ppt/theme/theme1.xml": gateTheme,
|
||||
})
|
||||
}
|
||||
|
||||
// gateBrokenPptx 复刻事故包:只有 presentation + slides + 幽灵 layout,
|
||||
// 无母版/主题/docProps,presentation 缺 sldMasterIdLst,slide 无关系表。
|
||||
func gateBrokenPptx(t *testing.T) []byte {
|
||||
// gateIncident2Pptx 复刻第二起事故(部件齐全但引用断裂,上版门禁放行的形态):
|
||||
// presentation/母版根元素漏 xmlns:r;sldIdLst 声明 3 页但关系表只有 rId3→slide1;
|
||||
// [Content_Types] 缺 slide2/3 的 Override。三个 fix 开关逐项修复以隔离断言。
|
||||
func gateIncident2Pptx(t *testing.T, fixNS, fixRels, fixCT bool) []byte {
|
||||
t.Helper()
|
||||
nsDecl := ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`
|
||||
pns, mns := "", ""
|
||||
if fixNS {
|
||||
pns, mns = nsDecl, nsDecl
|
||||
}
|
||||
pres := `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + pns + `><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId3"/><p:sldId id="257" r:id="rId4"/><p:sldId id="258" r:id="rId5"/></p:sldIdLst></p:presentation>`
|
||||
master := `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + mns + `><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
|
||||
rels := `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
|
||||
if fixRels {
|
||||
rels = relXML(
|
||||
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
|
||||
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`,
|
||||
`<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`,
|
||||
`<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide3.xml"/>`)
|
||||
}
|
||||
ctParts := []string{"ppt/presentation.xml", "ppt/slideMasters/slideMaster1.xml",
|
||||
"ppt/slideLayouts/slideLayout1.xml", "ppt/theme/theme1.xml", "ppt/slides/slide1.xml"}
|
||||
if fixCT {
|
||||
ctParts = append(ctParts, "ppt/slides/slide2.xml", "ppt/slides/slide3.xml")
|
||||
}
|
||||
return zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
"[Content_Types].xml": gateCT(ctParts...),
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": pres,
|
||||
"ppt/_rels/presentation.xml.rels": rels,
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slides/slide2.xml": gateSlide,
|
||||
"ppt/slides/slide3.xml": gateSlide,
|
||||
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
|
||||
"ppt/slides/_rels/slide2.xml.rels": relXML(relLayout),
|
||||
"ppt/slides/_rels/slide3.xml.rels": relXML(relLayout),
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
|
||||
"ppt/slideMasters/slideMaster1.xml": master,
|
||||
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
|
||||
"ppt/theme/theme1.xml": gateTheme,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,8 +142,17 @@ func TestOoxmlGateValidPptx(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
|
||||
err := ooxmlStructuralCheck("机械臂腹部超声设计方案.pptx", gateBrokenPptx(t))
|
||||
func TestOoxmlGateIncident1PartsMissing(t *testing.T) {
|
||||
// 第一类事故:部件整体缺失(无母版/主题/docProps、slide 无关系表)。
|
||||
broken := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
})
|
||||
err := ooxmlStructuralCheck("事故一.pptx", broken)
|
||||
if err == nil {
|
||||
t.Fatal("incident-shaped pptx must be rejected")
|
||||
}
|
||||
@@ -101,22 +163,49 @@ func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 第二类事故:部件齐全但引用断裂——上一版门禁放行、Office 拒开的形态。
|
||||
func TestOoxmlGateIncident2ReferenceBreaks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fixNS, fixRels, fixCT bool
|
||||
want string
|
||||
}{
|
||||
{"undeclared-ns", false, true, true, "未声明的命名空间前缀 xmlns:r"},
|
||||
{"sldid-dangling-rid", true, false, true, "sldIdLst 引用的 slide 在关系表中不存在"},
|
||||
{"ct-missing-slides", true, true, false, "Content_Types"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := ooxmlStructuralCheck("事故二.pptx", gateIncident2Pptx(t, tc.fixNS, tc.fixRels, tc.fixCT))
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 三处缺陷全部修复后必须放行(对应 P4 修复产物的形态)。
|
||||
if err := ooxmlStructuralCheck("修复后.pptx", gateIncident2Pptx(t, true, true, true)); err != nil {
|
||||
t.Fatalf("fully repaired incident-2 package must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
valid := gateValidPptx(t)
|
||||
zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
b, err := readZipFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m[f.Name] = string(b)
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -135,21 +224,40 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
|
||||
{"layout-no-master-rel", func(m map[string]string) {
|
||||
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML()
|
||||
}, "没有指向 slideMaster"},
|
||||
{"layout-master-not-listed", func(m map[string]string) {
|
||||
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster2.xml"/>`)
|
||||
m["ppt/slideMasters/slideMaster2.xml"] = gateMaster
|
||||
m["ppt/slideMasters/_rels/slideMaster2.xml.rels"] = relXML(relLayout, relTheme)
|
||||
}, "未在 presentation 的 sldMasterIdLst 中声明"},
|
||||
{"master-no-theme-rel", func(m map[string]string) {
|
||||
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML()
|
||||
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML(relLayout)
|
||||
}, "没有指向 theme"},
|
||||
{"theme-missing", func(m map[string]string) {
|
||||
delete(m, "ppt/theme/theme1.xml")
|
||||
}, "缺少必备部件"},
|
||||
{"master-no-layout-idlst", func(m map[string]string) {
|
||||
m["ppt/slideMasters/slideMaster1.xml"] = strings.Replace(gateMaster, `<p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst>`, "", 1)
|
||||
}, "缺少 <sldLayoutIdLst>"},
|
||||
{"pres-no-master-idlst", func(m map[string]string) {
|
||||
m["ppt/presentation.xml"] = strings.Replace(gatePres, "<p:sldMasterIdLst><p:sldMasterId id=\"1\"/></p:sldMasterIdLst>", "", 1)
|
||||
m["ppt/presentation.xml"] = strings.Replace(
|
||||
m["ppt/presentation.xml"],
|
||||
`<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst>`, "", 1)
|
||||
}, "sldMasterIdLst"},
|
||||
{"pres-no-slide-rels", func(m map[string]string) {
|
||||
{"pres-slide-rel-dangling", func(m map[string]string) {
|
||||
m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster)
|
||||
}, "未引用任何幻灯片"},
|
||||
}, "sldIdLst 引用的 slide 在关系表中不存在"},
|
||||
{"pres-extra-slide-rel", func(m map[string]string) {
|
||||
m["ppt/_rels/presentation.xml.rels"] = relXML(relSlide, relMaster,
|
||||
`<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`)
|
||||
m["ppt/slides/slide2.xml"] = gateSlide
|
||||
m["ppt/slides/_rels/slide2.xml.rels"] = relXML(relLayout)
|
||||
}, "数量不一致"},
|
||||
{"xml-malformed", func(m map[string]string) {
|
||||
m["ppt/slides/slide1.xml"] = "<p:sld>"
|
||||
m["ppt/slides/slide1.xml"] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">`
|
||||
}, "不是合法 XML"},
|
||||
{"ct-slide-mismatch", func(m map[string]string) {
|
||||
m["[Content_Types].xml"] = strings.Replace(gateCT(gateAllParts...), ctSlide, "application/xml", 1)
|
||||
}, "内容类型不符"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := base(t)
|
||||
@@ -164,6 +272,19 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func readZipFile(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
|
||||
validDocx := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
@@ -175,6 +296,15 @@ func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
|
||||
if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil {
|
||||
t.Fatal("docx without document.xml must be rejected")
|
||||
}
|
||||
// 命名空间严格化同样覆盖 docx:正文用了未声明前缀要拦。
|
||||
badDocx := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"word/document.xml": `<?xml version="1.0"?><w:document><w:p r:id="r1"/></w:document>`,
|
||||
})
|
||||
err := ooxmlStructuralCheck("方案.docx", badDocx)
|
||||
if err == nil || !strings.Contains(err.Error(), "未声明") {
|
||||
t.Fatalf("docx with undeclared prefix must be rejected: %v", err)
|
||||
}
|
||||
validXlsx := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="urn:x"/>`,
|
||||
@@ -203,7 +333,7 @@ func TestRegisterArtifactStructuralGate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := filepath.Join(sandbox, "坏.pptx")
|
||||
if err := os.WriteFile(broken, gateBrokenPptx(t), 0o644); err != nil {
|
||||
if err := os.WriteFile(broken, gateIncident2Pptx(t, false, false, false), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
core "agent-system-core"
|
||||
@@ -37,7 +39,8 @@ import (
|
||||
// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process
|
||||
// AccountDirectoryRegistrar for scoped, current account candidate decisions.
|
||||
// 2.14.0-experimental: optional RolePolicyProvider for the Backend-owned
|
||||
// single-effective-role policy consumed by trusted in-process domains.
|
||||
// single-effective-role policy, plus InProc.HostOptions / CLIHook and their
|
||||
// merge, flag-registration, and startup helpers for composed binaries.
|
||||
// 2.15.0-experimental: resource queries carry the current authority epoch so
|
||||
// downstream caches can reject stale role snapshots.
|
||||
const SurfaceVersion = "2.15.0-experimental"
|
||||
@@ -174,10 +177,24 @@ type IntentSpec struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。
|
||||
// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。
|
||||
type CLIHook struct {
|
||||
Register func(fs *flag.FlagSet)
|
||||
AfterParse func(opt Options) (handled bool, err error)
|
||||
}
|
||||
|
||||
// InProc 是随下游二进制编译进来的扩展。
|
||||
type InProc struct {
|
||||
ID string
|
||||
Register func(reg Registry) error
|
||||
// HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true”
|
||||
// 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。
|
||||
HostOptions func() Options
|
||||
// CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。
|
||||
CLI *CLIHook
|
||||
// Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。
|
||||
Prepare func(opt *Options) error
|
||||
}
|
||||
|
||||
// Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。
|
||||
@@ -213,6 +230,80 @@ var (
|
||||
runner func(Options) error
|
||||
)
|
||||
|
||||
// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。
|
||||
// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。
|
||||
// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。
|
||||
func MergeHostOptions(base Options) (Options, error) {
|
||||
for _, e := range Compiled() {
|
||||
if e.HostOptions == nil {
|
||||
continue
|
||||
}
|
||||
opt := e.HostOptions()
|
||||
if opt.DisableUploadIndexing {
|
||||
base.DisableUploadIndexing = true
|
||||
}
|
||||
if opt.DisableConvCardMirror {
|
||||
base.DisableConvCardMirror = true
|
||||
}
|
||||
if opt.DisableExecTools {
|
||||
base.DisableExecTools = true
|
||||
}
|
||||
if opt.ExecBackend == "" {
|
||||
continue
|
||||
}
|
||||
if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend {
|
||||
return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend)
|
||||
}
|
||||
if base.ExecBackend == "" {
|
||||
base.ExecBackend = opt.ExecBackend
|
||||
}
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。
|
||||
func RegisterCLIFlags(fs *flag.FlagSet) {
|
||||
if fs == nil {
|
||||
return
|
||||
}
|
||||
for _, e := range Compiled() {
|
||||
if e.CLI != nil && e.CLI.Register != nil {
|
||||
e.CLI.Register(fs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。
|
||||
// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。
|
||||
func RunCLIHooks(opt Options) (bool, error) {
|
||||
for _, e := range Compiled() {
|
||||
if e.CLI == nil || e.CLI.AfterParse == nil {
|
||||
continue
|
||||
}
|
||||
handled, err := e.CLI.AfterParse(opt)
|
||||
if err != nil || handled {
|
||||
return handled, err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。
|
||||
func PrepareCompiled(opt *Options) error {
|
||||
if opt == nil {
|
||||
return errors.New("extension: nil options")
|
||||
}
|
||||
for _, e := range Compiled() {
|
||||
if e.Prepare == nil {
|
||||
continue
|
||||
}
|
||||
if err := e.Prepare(opt); err != nil {
|
||||
return fmt.Errorf("extension: %s: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterCompiled 在 init 中登记进程内扩展。
|
||||
func RegisterCompiled(e InProc) {
|
||||
mu.Lock()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeHostOptionsORAndExecConflict(t *testing.T) {
|
||||
ResetCompiled()
|
||||
t.Cleanup(ResetCompiled)
|
||||
RegisterCompiled(InProc{
|
||||
ID: "a",
|
||||
HostOptions: func() Options {
|
||||
return Options{DisableUploadIndexing: true, ExecBackend: "sandbox"}
|
||||
},
|
||||
})
|
||||
RegisterCompiled(InProc{
|
||||
ID: "b",
|
||||
HostOptions: func() Options {
|
||||
return Options{DisableExecTools: true, DisableConvCardMirror: true}
|
||||
},
|
||||
})
|
||||
got, err := MergeHostOptions(Options{Addr: "127.0.0.1:1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.DisableUploadIndexing || !got.DisableExecTools || !got.DisableConvCardMirror {
|
||||
t.Fatalf("bool merge: %+v", got)
|
||||
}
|
||||
if got.ExecBackend != "sandbox" || got.Addr != "127.0.0.1:1" {
|
||||
t.Fatalf("kept caller addr and first exec backend: %+v", got)
|
||||
}
|
||||
RegisterCompiled(InProc{
|
||||
ID: "c",
|
||||
HostOptions: func() Options { return Options{ExecBackend: "direct"} },
|
||||
})
|
||||
if _, err := MergeHostOptions(Options{}); err == nil {
|
||||
t.Fatal("expected exec backend conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCompiledCanRejectStartup(t *testing.T) {
|
||||
ResetCompiled()
|
||||
t.Cleanup(ResetCompiled)
|
||||
RegisterCompiled(InProc{
|
||||
ID: "rdm",
|
||||
Prepare: func(opt *Options) error {
|
||||
if opt.ExecBackend == "sandbox" {
|
||||
return errors.New("no bwrap")
|
||||
}
|
||||
opt.DisableExecTools = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
opt := Options{ExecBackend: "sandbox"}
|
||||
if err := PrepareCompiled(&opt); err == nil {
|
||||
t.Fatal("expected prepare error")
|
||||
}
|
||||
opt.ExecBackend = "direct"
|
||||
if err := PrepareCompiled(&opt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opt.DisableExecTools {
|
||||
t.Fatal("prepare should be able to rewrite options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIHookHandlesBeforeServe(t *testing.T) {
|
||||
ResetCompiled()
|
||||
t.Cleanup(ResetCompiled)
|
||||
var admin *string
|
||||
RegisterCompiled(InProc{
|
||||
ID: "rdm",
|
||||
CLI: &CLIHook{
|
||||
Register: func(fs *flag.FlagSet) {
|
||||
admin = fs.String("bootstrap-admin", "", "owner")
|
||||
},
|
||||
AfterParse: func(opt Options) (bool, error) {
|
||||
if admin == nil || *admin == "" {
|
||||
return false, nil
|
||||
}
|
||||
if opt.DataDir == "" {
|
||||
return true, errors.New("missing data")
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
RegisterCLIFlags(fs)
|
||||
if err := fs.Parse([]string{"-bootstrap-admin", "owner"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fs.Lookup("data") != nil {
|
||||
t.Fatal("plugin must not redefine base flags")
|
||||
}
|
||||
handled, err := RunCLIHooks(Options{DataDir: "/tmp/data"})
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("handled=%v err=%v", handled, err)
|
||||
}
|
||||
fs2 := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
RegisterCLIFlags(fs2)
|
||||
if err := fs2.Parse(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handled, err = RunCLIHooks(Options{DataDir: "/tmp/data"})
|
||||
if err != nil || handled {
|
||||
t.Fatalf("empty flag should not handle: handled=%v err=%v", handled, err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ AccountSearcher.SearchAccounts
|
||||
AdminProvider
|
||||
AdminProvider.IsAdmin
|
||||
AdminZoneSpec
|
||||
CLIHook
|
||||
Compiled
|
||||
DelegatedProjectProjectionHost
|
||||
DelegatedProjectProjectionHost.ProjectProjectionForCreator
|
||||
@@ -50,9 +51,11 @@ HostClient
|
||||
InProc
|
||||
IntentSpec
|
||||
Main
|
||||
MergeHostOptions
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
Options
|
||||
PrepareCompiled
|
||||
ProjectHost
|
||||
ProjectHost.EnsureProject
|
||||
ProjectHost.OwnedProject
|
||||
@@ -64,6 +67,7 @@ ProjectHost.RenameOwnedProject
|
||||
ProjectLifecycle
|
||||
ProjectLifecycleRegistry
|
||||
ProjectLifecycleRegistry.RegisterProjectLifecycle
|
||||
RegisterCLIFlags
|
||||
RegisterCompiled
|
||||
Registry
|
||||
Registry.AddAdminZone
|
||||
@@ -103,6 +107,7 @@ RolePolicyProvider.RolePolicyForOwner
|
||||
RolePolicyProvider.RolePolicyForUser
|
||||
RolePolicySchemaVersion
|
||||
RolePolicySnapshot
|
||||
RunCLIHooks
|
||||
SetRunner
|
||||
SetScopedHostFactory
|
||||
SurfaceHost
|
||||
|
||||
Reference in New Issue
Block a user