121 lines
4.6 KiB
Go
121 lines
4.6 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
package chat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
)
|
|
|
|
// ValidateMessageExportOutput applies the same workspace-relative and
|
|
// no-symlink boundary used by message resource downloads. The export target is
|
|
// always a file; directory-shaped paths are rejected instead of inventing a
|
|
// name.
|
|
func ValidateMessageExportOutput(output string) error {
|
|
trimmed := strings.TrimSpace(output)
|
|
if strings.HasSuffix(trimmed, "/") ||
|
|
strings.HasSuffix(trimmed, string(os.PathSeparator)) {
|
|
return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录")
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(trimmed), ".json") {
|
|
return apperrors.NewValidation("--output 必须使用 .json 文件扩展名")
|
|
}
|
|
return validateResourceDownloadOutputFlag(output, "--output")
|
|
}
|
|
|
|
// WriteMessageExportJSON atomically publishes the exact structured message
|
|
// ledger. It defaults to no-clobber and never follows a symlink outside the
|
|
// current working directory.
|
|
func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) {
|
|
if err := ValidateMessageExportOutput(output); err != nil {
|
|
return "", 0, err
|
|
}
|
|
cwd, err := resourceGetwd()
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
|
}
|
|
base, err := resourceAbs(cwd)
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
|
}
|
|
realBase, err := resourceEvalSymlinks(base)
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
|
}
|
|
|
|
target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output)))
|
|
parent := filepath.Dir(target)
|
|
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
|
|
return "", 0, err
|
|
}
|
|
realParent, err := resourceEvalSymlinks(parent)
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
|
|
}
|
|
parentRel, err := resourceRel(realBase, realParent)
|
|
if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
|
|
return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录")
|
|
}
|
|
target = filepath.Join(realParent, filepath.Base(target))
|
|
if info, statErr := resourceLstat(target); statErr == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", 0, apperrors.NewValidation("--output 目标不能是符号链接")
|
|
}
|
|
if info.IsDir() {
|
|
return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件")
|
|
}
|
|
if !overwrite {
|
|
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
|
|
}
|
|
} else if !errors.Is(statErr, os.ErrNotExist) {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
|
|
}
|
|
|
|
rendered, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err))
|
|
}
|
|
rendered = append(rendered, '\n')
|
|
temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*")
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err))
|
|
}
|
|
tempPath := temp.Name()
|
|
defer func() {
|
|
_ = resourceTempClose(temp)
|
|
_ = os.Remove(tempPath)
|
|
}()
|
|
if _, err := temp.Write(rendered); err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err))
|
|
}
|
|
if err := resourceTempSync(temp); err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("同步消息导出失败: %v", err))
|
|
}
|
|
if err := resourceTempClose(temp); err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("关闭消息导出失败: %v", err))
|
|
}
|
|
if overwrite {
|
|
if err := resourceRename(tempPath, target); err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
|
|
}
|
|
} else if err := resourceLink(tempPath, target); err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
|
|
}
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
|
|
}
|
|
relativePath, err = resourceRel(realBase, target)
|
|
if err != nil {
|
|
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
|
|
}
|
|
return filepath.ToSlash(relativePath), len(rendered), nil
|
|
}
|