Compare commits

...

3 Commits

Author SHA1 Message Date
fangshuyu
15263efe30 fix: normalize batch delete block IDs 2026-07-22 19:09:46 +08:00
fangshuyu
5b67085b32 fix: preserve docs title compatibility 2026-07-22 19:07:04 +08:00
fangshuyu
da149e66ba fix: validate unsafe docs write inputs 2026-07-22 18:52:59 +08:00
5 changed files with 210 additions and 4 deletions

View File

@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "XML str_replace rejects multiline pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_delete rejects empty ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
wantParam: "--block-id",
},
{
name: "block_delete rejects duplicate ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},

View File

@@ -17,6 +17,46 @@ import (
// ── V2 (OpenAPI) tests ──
func TestStripTopLevelXMLTitles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want string
}{
{
name: "single title",
content: "<title>Content title</title><p>body</p>",
want: "<p>body</p>",
},
{
name: "multiple titles",
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
want: "<p>body</p>",
},
{
name: "nested title is preserved",
content: "<callout><title>Nested</title></callout><p>body</p>",
want: "<callout><title>Nested</title></callout><p>body</p>",
},
{
name: "malformed XML is preserved",
content: "<title>Content title</title><p>A & B</p>",
want: "<title>Content title</title><p>A & B</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
}
})
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()

View File

@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"strings"
"github.com/larksuite/cli/errs"
@@ -16,7 +18,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
if runtime.Str("doc-format") == "xml" {
content = stripTopLevelXMLTitles(content)
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
type docContentRange struct {
start int64
end int64
}
// stripTopLevelXMLTitles preserves the established --title-wins contract while
// avoiding duplicate-title warnings from XML content. If the fragment is not
// well-formed XML, it is left untouched for the service to diagnose.
func stripTopLevelXMLTitles(content string) string {
const wrapperStart = "<root>"
wrapped := wrapperStart + content + "</root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
wrapperLen := int64(len(wrapperStart))
depth := 0
activeStart := int64(-1)
ranges := make([]docContentRange, 0, 1)
for {
tokenStart := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return content
}
switch value := token.(type) {
case xml.StartElement:
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
activeStart = tokenStart - wrapperLen
}
depth++
case xml.EndElement:
depth--
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
activeStart = -1
}
}
}
if len(ranges) == 0 {
return content
}
var result strings.Builder
cursor := int64(0)
for _, item := range ranges {
result.WriteString(content[int(cursor):int(item.start)])
cursor = item.end
}
result.WriteString(content[int(cursor):])
return strings.TrimSpace(result.String())
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))

View File

@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
if err := validateBlockDeleteIDs(blockID); err != nil {
return err
}
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func validateBlockDeleteIDs(raw string) error {
seen := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
blockID := strings.TrimSpace(part)
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
}
if _, ok := seen[blockID]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
}
seen[blockID] = struct{}{}
}
return nil
}
func normalizeBlockDeleteIDs(raw string) string {
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, ",")
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
if cmd == "block_delete" {
blockID = normalizeBlockDeleteIDs(blockID)
}
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {

View File

@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA,blkB,blkC",
"--block-id", "blkA, blkB, blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Flag title",
"--content", "<title>Content title</title><p>body</p>",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
}
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
want string
}{
{
name: "multiline XML str_replace",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
want: "must be inline",
},
{
name: "duplicate block delete ID",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
want: "duplicate ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
})
}
}