From 1aaf65ec75b7b02efefe5c9e5ba361c02850ea0d Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 12:15:24 +0800 Subject: [PATCH 1/6] fix: preserve attachment metadata on base uploads --- shortcuts/base/base_execute_test.go | 6 +++- shortcuts/base/record_upload_attachment.go | 36 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 35b8b1e13..e9f432ba2 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1219,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { !strings.Contains(updateBody, `"image_height":480`) || !strings.Contains(updateBody, `"deprecated_set_attachment":true`) || !strings.Contains(updateBody, `"file_token":"file_tok_1"`) || - !strings.Contains(updateBody, `"name":"report.txt"`) { + !strings.Contains(updateBody, `"name":"report.txt"`) || + !strings.Contains(updateBody, `"size":16`) || + !strings.Contains(updateBody, `"mime_type":"text/plain"`) { t.Fatalf("update body=%s", updateBody) } }) @@ -1370,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { if !strings.Contains(updateBody, `"附件"`) || !strings.Contains(updateBody, `"file_token":"file_tok_big"`) || !strings.Contains(updateBody, `"name":"large-report.bin"`) || + !strings.Contains(updateBody, `"size":20971521`) || + !strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) || !strings.Contains(updateBody, `"deprecated_set_attachment":true`) { t.Fatalf("update body=%s", updateBody) } diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index de0615d1f..475d0433d 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -7,6 +7,9 @@ import ( "context" "errors" "fmt" + "io" + "mime" + "net/http" "path/filepath" "strings" @@ -269,10 +272,43 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, return nil, err } + mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName) + if err != nil { + return nil, err + } + attachment := map[string]interface{}{ "file_token": fileToken, "name": fileName, + "mime_type": mimeType, + "size": fileSize, "deprecated_set_attachment": true, } return attachment, nil } + +func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) { + if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" { + return stripMIMEParams(byExt), nil + } + + f, err := fio.Open(filePath) + if err != nil { + return "", common.WrapInputStatError(err) + } + defer f.Close() + + buf := make([]byte, 512) + n, readErr := f.Read(buf) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return "", output.ErrValidation("cannot read file: %s", readErr) + } + return stripMIMEParams(http.DetectContentType(buf[:n])), nil +} + +func stripMIMEParams(value string) string { + if i := strings.IndexByte(value, ';'); i != -1 { + value = value[:i] + } + return strings.TrimSpace(value) +} From df3d3a9d62c579b593f2cf6688fe0615bc9e4d86 Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 12:36:37 +0800 Subject: [PATCH 2/6] test: cover attachment mime detection --- shortcuts/base/record_upload_attachment.go | 45 +++++++- .../base/record_upload_attachment_test.go | 104 ++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 shortcuts/base/record_upload_attachment_test.go diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 475d0433d..84270b63e 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -4,14 +4,15 @@ package base import ( + "bytes" "context" "errors" "fmt" "io" "mime" - "net/http" "path/filepath" "strings" + "unicode/utf8" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" @@ -303,7 +304,7 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str if readErr != nil && !errors.Is(readErr, io.EOF) { return "", output.ErrValidation("cannot read file: %s", readErr) } - return stripMIMEParams(http.DetectContentType(buf[:n])), nil + return detectAttachmentMIMEFromContent(buf[:n]), nil } func stripMIMEParams(value string) string { @@ -312,3 +313,43 @@ func stripMIMEParams(value string) string { } return strings.TrimSpace(value) } + +func detectAttachmentMIMEFromContent(content []byte) string { + if len(content) == 0 { + return "application/octet-stream" + } + if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) { + return "image/png" + } + if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) { + return "image/jpeg" + } + if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) { + return "image/gif" + } + if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) { + return "image/webp" + } + if bytes.HasPrefix(content, []byte("%PDF-")) { + return "application/pdf" + } + if looksLikeText(content) { + return "text/plain" + } + return "application/octet-stream" +} + +func looksLikeText(content []byte) bool { + if !utf8.Valid(content) { + return false + } + for _, r := range string(content) { + if r == '\n' || r == '\r' || r == '\t' { + continue + } + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} diff --git a/shortcuts/base/record_upload_attachment_test.go b/shortcuts/base/record_upload_attachment_test.go new file mode 100644 index 000000000..579dc3857 --- /dev/null +++ b/shortcuts/base/record_upload_attachment_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "io" + "io/fs" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/extension/fileio" +) + +type attachmentTestFileIO struct { + openFile fileio.File + openErr error +} + +func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr } +func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) { + return attachmentTestFileInfo{}, nil +} +func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil } +func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, nil +} + +type attachmentTestFileInfo struct{} + +func (attachmentTestFileInfo) Size() int64 { return 0 } +func (attachmentTestFileInfo) IsDir() bool { return false } +func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 } + +type attachmentTestFile struct { + *bytes.Reader +} + +func newAttachmentTestFile(content []byte) attachmentTestFile { + return attachmentTestFile{Reader: bytes.NewReader(content)} +} + +func (attachmentTestFile) Close() error { return nil } + +type attachmentReadErrorFile struct{} + +func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission } +func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF } +func (attachmentReadErrorFile) Close() error { return nil } + +func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) { + got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "text/plain" { + t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain") + } +} + +func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) { + fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))} + + got, err := detectAttachmentMIMEType(fio, "note", "note") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "text/plain" { + t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain") + } +} + +func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) { + fio := attachmentTestFileIO{openErr: os.ErrNotExist} + + _, err := detectAttachmentMIMEType(fio, "missing", "missing") + if err == nil { + t.Fatal("expected error for open failure") + } + if !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error = %v, want wrapped read failure", err) + } +} + +func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) { + fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}} + + _, err := detectAttachmentMIMEType(fio, "broken", "broken") + if err == nil { + t.Fatal("expected error for read failure") + } + if !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error = %v, want read failure", err) + } +} + +func TestDetectAttachmentMIMEFromContentBinaryFallback(t *testing.T) { + got := detectAttachmentMIMEFromContent([]byte{0x00, 0x01, 0x02, 0x03}) + if got != "application/octet-stream" { + t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, "application/octet-stream") + } +} From cae8ef9bdd8709a3f156b87defed25de3a5a471d Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 12:50:27 +0800 Subject: [PATCH 3/6] fix: address attachment upload review feedback --- shortcuts/base/base_dryrun_ops_test.go | 2 ++ shortcuts/base/record_upload_attachment.go | 13 ++++---- .../base/record_upload_attachment_test.go | 30 ++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 64d53bdfd..22b0e07ea 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) { "bitable_file", "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", "report-final.pdf", + `"mime_type":"\u003cdetected_mime_type\u003e"`, + `"size":"\u003cfile_size\u003e"`, "deprecated_set_attachment", ) } diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 84270b63e..0b781b215 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -109,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont map[string]interface{}{ "file_token": "", "name": fileName, + "mime_type": "", + "size": "", "deprecated_set_attachment": true, }, }, @@ -247,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i } func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) { + mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName) + if err != nil { + return nil, err + } + parentNode := baseToken var ( fileToken string - err error ) if fileSize <= common.MaxDriveMediaUploadSinglePartSize { fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ @@ -273,11 +279,6 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, return nil, err } - mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName) - if err != nil { - return nil, err - } - attachment := map[string]interface{}{ "file_token": fileToken, "name": fileName, diff --git a/shortcuts/base/record_upload_attachment_test.go b/shortcuts/base/record_upload_attachment_test.go index 579dc3857..54fee9d15 100644 --- a/shortcuts/base/record_upload_attachment_test.go +++ b/shortcuts/base/record_upload_attachment_test.go @@ -96,9 +96,31 @@ func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) { } } -func TestDetectAttachmentMIMEFromContentBinaryFallback(t *testing.T) { - got := detectAttachmentMIMEFromContent([]byte{0x00, 0x01, 0x02, 0x03}) - if got != "application/octet-stream" { - t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, "application/octet-stream") +func TestDetectAttachmentMIMEFromContent(t *testing.T) { + tests := []struct { + name string + content []byte + want string + }{ + {name: "empty", content: nil, want: "application/octet-stream"}, + {name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"}, + {name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"}, + {name: "gif87a", content: []byte("GIF87a"), want: "image/gif"}, + {name: "gif89a", content: []byte("GIF89a"), want: "image/gif"}, + {name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"}, + {name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"}, + {name: "text", content: []byte("hello from base attachment"), want: "text/plain"}, + {name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"}, + {name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"}, + {name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectAttachmentMIMEFromContent(tt.content) + if got != tt.want { + t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want) + } + }) } } From a985458c87ba44278ea75c4f33eac5e6b60f8cef Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 13:37:07 +0800 Subject: [PATCH 4/6] fix: preserve source extension for attachment mime detection --- shortcuts/base/record_upload_attachment.go | 3 +++ shortcuts/base/record_upload_attachment_test.go | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 0b781b215..67fde80f8 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -293,6 +293,9 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" { return stripMIMEParams(byExt), nil } + if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" { + return stripMIMEParams(byExt), nil + } f, err := fio.Open(filePath) if err != nil { diff --git a/shortcuts/base/record_upload_attachment_test.go b/shortcuts/base/record_upload_attachment_test.go index 54fee9d15..69ff360e2 100644 --- a/shortcuts/base/record_upload_attachment_test.go +++ b/shortcuts/base/record_upload_attachment_test.go @@ -60,6 +60,16 @@ func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) { } } +func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) { + got, err := detectAttachmentMIMEType(nil, "report.docx", "report") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { + t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got) + } +} + func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) { fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))} From c1d12d0cf1af9e0733f81158e7370b1a85ae52be Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 13:45:19 +0800 Subject: [PATCH 5/6] fix: avoid registry test refresh data race --- internal/registry/remote.go | 15 +++++++++++++-- internal/registry/remote_test.go | 3 +-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/registry/remote.go b/internal/registry/remote.go index 4fcaa357c..c7e42d1c2 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -255,14 +255,25 @@ func doSyncFetch() { // --- background refresh --- -var refreshOnce sync.Once +var ( + refreshOnce sync.Once + backgroundRefreshWG sync.WaitGroup +) func triggerBackgroundRefresh() { refreshOnce.Do(func() { - go doBackgroundRefresh() + backgroundRefreshWG.Add(1) + go func() { + defer backgroundRefreshWG.Done() + doBackgroundRefresh() + }() }) } +func waitForBackgroundRefresh() { + backgroundRefreshWG.Wait() +} + func doBackgroundRefresh() { defer func() { _ = recover() }() meta, _ := loadCacheMeta() diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index c8b2f77e8..4b3ed5f5b 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -19,6 +19,7 @@ import ( // resetInit resets the package-level state so each test starts fresh. func resetInit() { + waitForBackgroundRefresh() initOnce = sync.Once{} mergedServices = make(map[string]map[string]interface{}) mergedProjectList = nil @@ -445,8 +446,6 @@ func TestFetchRemoteMerged_InvalidJSON(t *testing.T) { } func TestBrandSwitchInvalidatesCache(t *testing.T) { - // Wait for any background goroutines from previous tests to settle - time.Sleep(200 * time.Millisecond) resetInit() tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) From 8ec64411704974a0503a3791ca28821c14dc441e Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 20 Apr 2026 13:46:53 +0800 Subject: [PATCH 6/6] Revert "fix: avoid registry test refresh data race" This reverts commit c1d12d0cf1af9e0733f81158e7370b1a85ae52be. --- internal/registry/remote.go | 15 ++------------- internal/registry/remote_test.go | 3 ++- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/internal/registry/remote.go b/internal/registry/remote.go index c7e42d1c2..4fcaa357c 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -255,25 +255,14 @@ func doSyncFetch() { // --- background refresh --- -var ( - refreshOnce sync.Once - backgroundRefreshWG sync.WaitGroup -) +var refreshOnce sync.Once func triggerBackgroundRefresh() { refreshOnce.Do(func() { - backgroundRefreshWG.Add(1) - go func() { - defer backgroundRefreshWG.Done() - doBackgroundRefresh() - }() + go doBackgroundRefresh() }) } -func waitForBackgroundRefresh() { - backgroundRefreshWG.Wait() -} - func doBackgroundRefresh() { defer func() { _ = recover() }() meta, _ := loadCacheMeta() diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 4b3ed5f5b..c8b2f77e8 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -19,7 +19,6 @@ import ( // resetInit resets the package-level state so each test starts fresh. func resetInit() { - waitForBackgroundRefresh() initOnce = sync.Once{} mergedServices = make(map[string]map[string]interface{}) mergedProjectList = nil @@ -446,6 +445,8 @@ func TestFetchRemoteMerged_InvalidJSON(t *testing.T) { } func TestBrandSwitchInvalidatesCache(t *testing.T) { + // Wait for any background goroutines from previous tests to settle + time.Sleep(200 * time.Millisecond) resetInit() tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)