diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1764fb19d2..25c5bead1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - RELEASE_GO_VERSION: '1.26.5' + RELEASE_GO_VERSION: '1.26.8' concurrency: group: release-${{ github.ref_name }} diff --git a/Makefile b/Makefile index 671c0e69e2..f462578b23 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -# The repository's Go 1.23 CI toolchain does not support -race on riscv64. +# The repository's Go 1.25 CI toolchain does not support -race on riscv64. # Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`) # over `go env GOARCH`, because command-line make variables are not visible to # $(shell ...). diff --git a/README.md b/README.md index db720a6379..444b8362f9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # lark-cli [](https://opensource.org/licenses/MIT) -[](https://go.dev/) +[](https://go.dev/) [](https://www.npmjs.com/package/@larksuite/cli) [中文版](./README.zh.md) | [English](./README.md) @@ -59,7 +59,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t Before you start, make sure you have: - Node.js (`npm`/`npx`) -- Go `v1.23`+ and Python 3 (only required for building from source) +- Go `v1.25`+ and Python 3 (only required for building from source) ### Quick Start (Human Users) @@ -77,7 +77,7 @@ npx @larksuite/cli@latest install **Option 2 — From source:** -Requires Go `v1.23`+ and Python 3. +Requires Go `v1.25`+ and Python 3. ```bash git clone https://github.com/larksuite/cli.git diff --git a/README.zh.md b/README.zh.md index 95722b39d6..88506c79d2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,7 +1,7 @@ # lark-cli [](https://opensource.org/licenses/MIT) -[](https://go.dev/) +[](https://go.dev/) [](https://www.npmjs.com/package/@larksuite/cli) [中文版](./README.zh.md) | [English](./README.md) @@ -59,7 +59,7 @@ 开始之前,请确保具备以下条件: - Node.js(`npm`/`npx`) -- Go `v1.23`+ 和 Python 3(仅源码构建需要) +- Go `v1.25`+ 和 Python 3(仅源码构建需要) ### 快速开始(人类用户) @@ -77,7 +77,7 @@ npx @larksuite/cli@latest install **方式二 — 从源码安装:** -需要 Go `v1.23`+ 和 Python 3。 +需要 Go `v1.25`+ 和 Python 3。 ```bash git clone https://github.com/larksuite/cli.git diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 182a6b1036..8f2fab7739 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -346,7 +346,7 @@ func authLoginRun(opts *LoginOptions, resolver domainResolver) error { return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } } else { - fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) + fmt.Fprint(f.IOStreams.ErrOut, msg.OpenURL) fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) if f.IOStreams != nil && !f.IOStreams.IsTerminal { fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint(renderContext)) diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 998152762e..3a8f5e9218 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -4,6 +4,7 @@ package auth import ( + "errors" "fmt" "sort" "strings" @@ -102,7 +103,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra Value(&selectedDomains). Validate(func(s []string) error { if len(s) == 0 { - return fmt.Errorf(msg.ErrNoDomain) + return errors.New(msg.ErrNoDomain) //nolint:forbidigo // huh inline validation text; never reaches the error envelope } return nil }), @@ -140,7 +141,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra if permLevel == "common" { permLabel = msg.PermCommonLabel } - fmt.Fprintf(ios.ErrOut, msg.Summary) + fmt.Fprint(ios.ErrOut, msg.Summary) fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", ")) fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel) scopePreview := strings.Join(scopes, ", ") diff --git a/go.mod b/go.mod index 8839b7fc71..0c50555d71 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/larksuite/cli -go 1.23.0 +go 1.25.14 require ( github.com/Microsoft/go-winio v0.6.2 @@ -19,12 +19,12 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/zalando/go-keyring v0.2.8 - golang.org/x/image v0.30.0 - golang.org/x/net v0.33.0 - golang.org/x/sync v0.16.0 - golang.org/x/sys v0.33.0 - golang.org/x/term v0.27.0 - golang.org/x/text v0.28.0 + golang.org/x/image v0.45.0 + golang.org/x/net v0.58.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 + golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -46,7 +46,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect diff --git a/go.sum b/go.sum index 574157832b..2a090684a8 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,9 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= @@ -139,34 +140,34 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= -golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/scripts/release-workflow.test.sh b/scripts/release-workflow.test.sh index fb5d49cd92..6ac60b4d8a 100755 --- a/scripts/release-workflow.test.sh +++ b/scripts/release-workflow.test.sh @@ -49,7 +49,7 @@ jobs.each do |job_name, job| end end -expect_equal(workflow.dig("env", "RELEASE_GO_VERSION"), "1.26.5", "release Go version") +expect_equal(workflow.dig("env", "RELEASE_GO_VERSION"), "1.26.8", "release Go version") expected_jobs = %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm retry-guidance] expect_equal(jobs.keys.sort, expected_jobs.sort, "release jobs") diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 097a162245..1f4208c703 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -171,7 +171,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([ if opts.allowNil { return nil, nil } - return nil, baseFlagErrorf(opts.typeError) + return nil, baseFlagErrorf("%s", opts.typeError) case []interface{}: rawItems = typed case []string: @@ -180,13 +180,13 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([ rawItems = append(rawItems, item) } default: - return nil, baseFlagErrorf(opts.typeError) + return nil, baseFlagErrorf("%s", opts.typeError) } if len(rawItems) == 0 { if opts.allowEmpty { return nil, nil } - return nil, baseFlagErrorf(opts.emptyError) + return nil, baseFlagErrorf("%s", opts.emptyError) } if opts.max > 0 && len(rawItems) > opts.max { return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems)) diff --git a/shortcuts/mail/draft/htmltext.go b/shortcuts/mail/draft/htmltext.go index fbaa585a84..04c0b6a2c7 100644 --- a/shortcuts/mail/draft/htmltext.go +++ b/shortcuts/mail/draft/htmltext.go @@ -19,7 +19,10 @@ import ( func plainTextFromHTML(raw string) string { doc, err := xhtml.Parse(strings.NewReader(raw)) if err != nil { - return strings.TrimSpace(raw) + // x/net/html rejects documents whose open-element stack exceeds 512 + // nodes (its stack-exhaustion CVE fix). The tokenizer has no such + // limit, so hostile nesting still yields text instead of raw markup. + return plainTextFromHTMLTokens(raw) } var buf bytes.Buffer @@ -36,9 +39,7 @@ func plainTextFromHTML(raw string) string { // all children processed — emit post-children block boundary, then pop if top.child == nil { - if isHTMLBlockBoundary(top.node) && buf.Len() > 0 && bufLastByte(&buf) != '\n' { - buf.WriteByte('\n') - } + writeBlockBoundary(&buf, top.node) stack = stack[:len(stack)-1] continue } @@ -53,19 +54,11 @@ func plainTextFromHTML(raw string) string { // emit text content if n.Type == xhtml.TextNode { - text := collapseHTMLWhitespace(n.Data) - if text != "" { - if last := bufLastByte(&buf); last != 0 && last != '\n' && last != ' ' { - buf.WriteByte(' ') - } - buf.WriteString(text) - } + writePlainText(&buf, n.Data) } // pre-children block boundary newline - if isHTMLBlockBoundary(n) && buf.Len() > 0 && bufLastByte(&buf) != '\n' { - buf.WriteByte('\n') - } + writeBlockBoundary(&buf, n) // push this node so its children get processed next if n.FirstChild != nil { @@ -73,6 +66,183 @@ func plainTextFromHTML(raw string) string { } } + return joinPlainTextLines(&buf) +} + +// plainTextFromHTMLTokens extracts text with the streaming tokenizer, which +// builds no tree and therefore has no nesting limit. It mirrors the parser's +// head handling so both paths drop the same content: everything the parser +// would place in
(including an implicit head before any tag and +// head elements that appear between and ) is never emitted, the +// body starts at , at the first start tag that is not allowed in head, +// or at the first non-whitespace text, and a stray inside the body is +// ignored the way the parser ignores it. +func plainTextFromHTMLTokens(raw string) string { + w := &tokenTextWriter{} + z := xhtml.NewTokenizer(strings.NewReader(raw)) + for { + switch z.Next() { + case xhtml.ErrorToken: + return joinPlainTextLines(&w.buf) + case xhtml.StartTagToken, xhtml.SelfClosingTagToken: + w.startTag(tokenElement(z)) + case xhtml.EndTagToken: + w.endTag(tokenElement(z)) + case xhtml.TextToken: + w.text(string(z.Text())) + } + } +} + +// headPhase follows the parser's insertion modes around : text and +// head-only elements are dropped until the body starts. +type headPhase int + +const ( + beforeHead headPhase = iota + inHead + afterHead + inBody +) + +// tokenTextWriter holds the tokenizer walk state. skip lists the open +// containers whose text is never emitted, innermost last; an end tag pops +// only when its name is on the stack, so a stray inside a +// cannot end the skip early. phase never changes while skip is +// non-empty because skipped contents are inert. +type tokenTextWriter struct { + buf bytes.Buffer + phase headPhase + skip []string +} + +func (w *tokenTextWriter) startTag(el *xhtml.Node) { + name := strings.ToLower(el.Data) + if len(w.skip) > 0 { + if w.skipsText(name) { + w.skip = append(w.skip, name) + } + return + } + if w.phase != inBody { + switch { + case name == "html": + return + case name == "head": + if w.phase == beforeHead { + w.phase = inHead + } + return + case name == "body": + w.phase = inBody + return + case isHeadElement(name): + if w.skipsText(name) { + w.skip = append(w.skip, name) + } + return + } + w.phase = inBody + } + if w.skipsText(name) { + w.skip = append(w.skip, name) + return + } + writeBlockBoundary(&w.buf, el) +} + +func (w *tokenTextWriter) endTag(el *xhtml.Node) { + name := strings.ToLower(el.Data) + for i := len(w.skip) - 1; i >= 0; i-- { + if w.skip[i] == name { + w.skip = w.skip[:i] + return + } + } + if len(w.skip) > 0 { + return + } + switch name { + case "head": + if w.phase == beforeHead || w.phase == inHead { + w.phase = afterHead + } + return + case "html", "body": + return + } + if w.phase != inBody { + return + } + writeBlockBoundary(&w.buf, el) +} + +func (w *tokenTextWriter) text(s string) { + if len(w.skip) > 0 { + return + } + if w.phase != inBody { + if collapseHTMLWhitespace(s) == "" { + return + } + w.phase = inBody + } + writePlainText(&w.buf, s) +} + +// skipsText reports whether name opens a container whose text must not be +// emitted: script/style/noscript/title anywhere, plus template and noframes +// while the parser would still place them in head (it keeps their text in +// body). +func (w *tokenTextWriter) skipsText(name string) bool { + switch name { + case "script", "style", "noscript", "title": + return true + case "template", "noframes": + return w.phase != inBody + default: + return false + } +} + +func tokenElement(z *xhtml.Tokenizer) *xhtml.Node { + name, _ := z.TagName() + return &xhtml.Node{Type: xhtml.ElementNode, Data: string(name)} +} + +// isHeadElement lists the elements the HTML parser keeps inside ; any +// other start tag implicitly ends the head. +func isHeadElement(name string) bool { + switch name { + case "base", "basefont", "bgsound", "link", "meta", "noframes", "noscript", "script", "style", "template", "title": + return true + default: + return false + } +} + +// writePlainText appends collapsed text, separating it from preceding inline +// text with a single space. +func writePlainText(buf *bytes.Buffer, s string) { + text := collapseHTMLWhitespace(s) + if text == "" { + return + } + if last := bufLastByte(buf); last != 0 && last != '\n' && last != ' ' { + buf.WriteByte(' ') + } + buf.WriteString(text) +} + +// writeBlockBoundary starts a new line at a block-level element unless the +// buffer is empty or already ends with one. +func writeBlockBoundary(buf *bytes.Buffer, n *xhtml.Node) { + if isHTMLBlockBoundary(n) && buf.Len() > 0 && bufLastByte(buf) != '\n' { + buf.WriteByte('\n') + } +} + +func joinPlainTextLines(buf *bytes.Buffer) string { lines := strings.Split(buf.String(), "\n") out := make([]string, 0, len(lines)) for _, line := range lines { diff --git a/shortcuts/mail/draft/htmltext_test.go b/shortcuts/mail/draft/htmltext_test.go index 8d7e1bb0a8..14ba343f28 100644 --- a/shortcuts/mail/draft/htmltext_test.go +++ b/shortcuts/mail/draft/htmltext_test.go @@ -60,19 +60,128 @@ func TestPlainTextFromHTML(t *testing.T) { func TestPlainTextFromHTMLDeepNesting(t *testing.T) { // Build HTML with 10000 levels of nesting — would overflow the stack - // with the old recursive implementation. + // with the old recursive implementation, and exceeds the 512-node open + // element limit that x/net/html's parser enforces, so this exercises the + // tokenizer fallback: block boundaries, skipped script content and + // entity unescaping must still behave like the parsed path. const depth = 10_000 var b strings.Builder for i := 0; i < depth; i++ { b.WriteString("deep & nested
end
") for i := 0; i < depth; i++ { b.WriteString("Hello world
bye") + if want := "Hello world\nbye"; got != want { + t.Errorf("plainTextFromHTMLTokens() = %q, want %q", got, want) + } +} + +// TestPlainTextFromHTMLFallbackMatchesParsedPath feeds the same document to +// the parsed path (shallow) and to the tokenizer fallback (the same document +// with a 600-level wrapper that trips the parser's 512-node limit) +// and requires identical output, so the fallback cannot leak content the +// parser drops — notably anything inside . +func TestPlainTextFromHTMLFallbackMatchesParsedPath(t *testing.T) { + deepWrap := func(inner string) string { + return strings.Repeat("", 600) + inner + strings.Repeat("", 600) + } + for _, test := range []struct { + name string + shallow string + deep string + want string + }{ + { + name: "template inside head is dropped", + shallow: "HIDDENVISIBLE
", + deep: "" + deepWrap("HIDDEN") + "VISIBLE
", + want: "VISIBLE", + }, + { + name: "template inside body is kept", + shallow: "TVISIBLE
", + deep: "" + deepWrap("T") + "VISIBLE
", + want: "T\nVISIBLE", + }, + { + name: "omitted head end tag still drops head content", + shallow: "VISIBLE
", + deep: "VISIBLE
", + want: "VISIBLE", + }, + { + name: "noframes inside head is dropped", + shallow: "TEXT
", + deep: "" + deepWrap("HIDDEN") + "TEXT
", + want: "TEXT", + }, + { + name: "template between head end and body goes back into head", + shallow: "HIDDENTEXT
", + deep: "" + deepWrap("HIDDEN") + "TEXT
", + want: "TEXT", + }, + { + name: "stray head tag inside body is ignored", + shallow: "A
TB
", + deep: "A
T" + strings.Repeat("B
", + want: "A\nT\nB", + }, + { + name: "mismatched script end tag does not end a head template", + shallow: "Hello
", + deep: "Hello
", + want: "Hello", + }, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := xhtml.Parse(strings.NewReader(test.shallow)); err != nil { + t.Fatalf("shallow input unexpectedly rejected by the parser: %v", err) + } + if _, err := xhtml.Parse(strings.NewReader(test.deep)); err == nil { + t.Fatal("deep input was accepted by the parser, so the fallback was not exercised") + } + if got := plainTextFromHTML(test.shallow); got != test.want { + t.Errorf("parsed path = %q, want %q", got, test.want) + } + if got := plainTextFromHTML(test.deep); got != test.want { + t.Errorf("fallback path = %q, want %q", got, test.want) + } + }) } }