Skip to content

Commit ca324c5

Browse files
committed
feat(sdk/go): provider 侧文件下发接收原语——hotpatch P1 传输层切片 1
- 协议:MsgProviderFilePushRequest/Response (0x050109/0x05010A), protobuf 兼容 wire 手写编解码(零 proto 再生成) - FilePushRequest {transfer_id, file_name, content_sha256, data} → 校验链:总开关/大小上限/仅 basename(拒穿越)/sha256 → 原子落盘 暂存目录(tmp+rename),**不自动应用**(hotpatch runner 后续编排) - 响应 {transfer_id, ok, stored_path, error} - 安全约束 10 例 + 编解码 2 例 + 落盘 1 例 + 帧回路 1 例 - 矩阵:文件下发接收 Go ✅(其余语言待对齐)
1 parent 16cfc8e commit ca324c5

6 files changed

Lines changed: 480 additions & 2 deletions

File tree

sdks/SDK_FEATURE_MATRIX.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
| OpenAPI 注册 helper(`RegisterFromOpenAPI` 等价) |||||||
5151
| JSON Schema 入站 payload 校验(provider 侧,`validateInputPayloads`||||||`JsonSchemaValidator` |
5252
| 控制面 manifest 上传(`control_addr``RegisterCapabilitiesRequest`|||||||
53-
| 文件传输(`enable_file_transfer` | ||||||
53+
| 文件下发接收(`enableFileTransfer`,暂存区落盘,hotpatch P1 传输层) | ||||||
5454

5555
### L3 Invoker(invoke / startTask / getTaskStatus / streamTask / cancelTask)
5656

@@ -68,7 +68,9 @@ C# DI / Unity / Java Spring Boot starter),明细见下文第五章;此层
6868
1. **manifest 上传(L2)**:六语言均已实现(注册后独立短连接
6969
`control_addr``RegisterCapabilitiesRequest`,best-effort 不阻断注册);
7070
已知边界:C++ 端到端帧回路测试未覆盖(编译与逻辑对齐 Go 已验证版本)。
71-
2. **文件传输(L2)**:六语言均未实现;如无平台侧需求建议从矩阵移除或标注"规划中"。
71+
2. **文件下发接收(L2)**:Go 已实现(`MsgProviderFilePushRequest 0x050109`
72+
校验链:开关/大小/ basename/ sha256/ 暂存区原子落盘,**不自动应用**——
73+
应用由 hotpatch runner 单独编排);其余语言待对齐。
7274

7375
---
7476

sdks/go/pkg/croupier/file_push.go

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
package croupier
2+
3+
// F:Provider 侧文件下发原语(hotpatch P1 传输层切片 1)。
4+
//
5+
// 安全约束(全部强制,缺一拒绝):
6+
// - 总开关 EnableFileTransfer(默认关)
7+
// - 大小上限 MaxFileSize(默认 10MB)
8+
// - 文件名仅允许 basename(拒绝路径穿越/绝对路径/隐藏分隔符)
9+
// - sha256 校验(传输完整性)
10+
// - 只落盘到暂存目录(FileStagingDir),**不自动应用**——应用由后续
11+
// hotpatch runner(备份→替换→自检→回滚)单独编排。
12+
//
13+
// wire(protobuf 兼容,手写编解码避免全 SDK 再生成):
14+
// FilePushRequest { 1: transfer_id, 2: file_name, 3: content_sha256(hex), 4: data }
15+
// FilePushResponse { 1: transfer_id, 2: ok, 3: stored_path, 4: error }
16+
17+
import (
18+
"crypto/sha256"
19+
"encoding/hex"
20+
"errors"
21+
"fmt"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
)
26+
27+
// filePushRequest 手写解码结果。
28+
type filePushRequest struct {
29+
transferID string
30+
fileName string
31+
contentSha256 string
32+
data []byte
33+
}
34+
35+
// encodeFilePushRequest 手写 protobuf 兼容编码(测试用)。
36+
func encodeFilePushRequest(req *filePushRequest) []byte {
37+
var out []byte
38+
appendStringField := func(field int, value string) {
39+
if value == "" {
40+
return
41+
}
42+
out = append(out, byte(field<<3|2))
43+
out = appendVarint(out, uint64(len(value)))
44+
out = append(out, value...)
45+
}
46+
appendBytesField := func(field int, value []byte) {
47+
if len(value) == 0 {
48+
return
49+
}
50+
out = append(out, byte(field<<3|2))
51+
out = appendVarint(out, uint64(len(value)))
52+
out = append(out, value...)
53+
}
54+
appendStringField(1, req.transferID)
55+
appendStringField(2, req.fileName)
56+
appendStringField(3, req.contentSha256)
57+
appendBytesField(4, req.data)
58+
return out
59+
}
60+
61+
func appendVarint(out []byte, value uint64) []byte {
62+
for value >= 0x80 {
63+
out = append(out, byte(value)|0x80)
64+
value >>= 7
65+
}
66+
return append(out, byte(value))
67+
}
68+
69+
// readVarint 从 buf 偏移 idx 读取 varint,返回 (value, 新偏移)。
70+
func readVarint(buf []byte, idx int) (uint64, int, error) {
71+
var value uint64
72+
var shift uint
73+
for {
74+
if idx >= len(buf) {
75+
return 0, idx, errors.New("truncated varint")
76+
}
77+
b := buf[idx]
78+
idx++
79+
value |= uint64(b&0x7F) << shift
80+
if b < 0x80 {
81+
return value, idx, nil
82+
}
83+
shift += 7
84+
if shift > 63 {
85+
return 0, idx, errors.New("varint overflow")
86+
}
87+
}
88+
}
89+
90+
// decodeFilePushRequest 手写 protobuf 兼容解码(未知字段跳过)。
91+
func decodeFilePushRequest(body []byte) (*filePushRequest, error) {
92+
req := &filePushRequest{}
93+
idx := 0
94+
for idx < len(body) {
95+
tag, next, err := readVarint(body, idx)
96+
if err != nil {
97+
return nil, err
98+
}
99+
idx = next
100+
field := int(tag >> 3)
101+
wireType := int(tag & 0x7)
102+
if wireType != 2 { // length-delimited
103+
return nil, fmt.Errorf("unsupported wire type %d", wireType)
104+
}
105+
length, next, err := readVarint(body, idx)
106+
if err != nil {
107+
return nil, err
108+
}
109+
idx = next
110+
if idx+int(length) > len(body) {
111+
return nil, errors.New("truncated field")
112+
}
113+
value := body[idx : idx+int(length)]
114+
idx += int(length)
115+
switch field {
116+
case 1:
117+
req.transferID = string(value)
118+
case 2:
119+
req.fileName = string(value)
120+
case 3:
121+
req.contentSha256 = string(value)
122+
case 4:
123+
req.data = append([]byte(nil), value...)
124+
}
125+
}
126+
return req, nil
127+
}
128+
129+
// safeStagingPath 校验文件名仅含 basename 且落点仍在暂存目录内
130+
// (防御路径穿越:../、绝对路径、分隔符变体)。
131+
func safeStagingPath(stagingDir, fileName string) (string, error) {
132+
if strings.TrimSpace(fileName) == "" {
133+
return "", errors.New("file name is required")
134+
}
135+
if strings.ContainsAny(fileName, "/\\") || strings.Contains(fileName, "..") ||
136+
filepath.IsAbs(fileName) || fileName == "." || fileName == ".." {
137+
return "", fmt.Errorf("file name must be a bare basename: %q", fileName)
138+
}
139+
clean := filepath.Clean(filepath.Join(stagingDir, fileName))
140+
if !strings.HasPrefix(clean, filepath.Clean(stagingDir)+string(os.PathSeparator)) {
141+
return "", fmt.Errorf("resolved path escapes staging dir: %q", clean)
142+
}
143+
return clean, nil
144+
}
145+
146+
// validateFilePush 对入站文件推送做全部安全与完整性校验。
147+
// 返回错误消息(已含原因);通过返回空串。
148+
func (m *TCPManager) validateFilePush(req *filePushRequest) error {
149+
if !m.config.EnableFileTransfer {
150+
return errors.New("file transfer is disabled on this provider")
151+
}
152+
if strings.TrimSpace(req.transferID) == "" {
153+
return errors.New("transfer_id is required")
154+
}
155+
if _, err := safeStagingPath(m.fileStagingDir(), req.fileName); err != nil {
156+
return err
157+
}
158+
maxSize := m.config.MaxFileSize
159+
if maxSize <= 0 {
160+
maxSize = 10 * 1024 * 1024
161+
}
162+
if len(req.data) == 0 {
163+
return errors.New("file payload is empty")
164+
}
165+
if len(req.data) > maxSize {
166+
return fmt.Errorf("file size %d exceeds max %d", len(req.data), maxSize)
167+
}
168+
if strings.TrimSpace(req.contentSha256) == "" {
169+
return errors.New("content_sha256 is required")
170+
}
171+
sum := sha256.Sum256(req.data)
172+
if !strings.EqualFold(hex.EncodeToString(sum[:]), strings.TrimSpace(req.contentSha256)) {
173+
return errors.New("checksum mismatch")
174+
}
175+
return nil
176+
}
177+
178+
// fileStagingDir 返回暂存目录(默认 ./croupier-staging),确保存在。
179+
func (m *TCPManager) fileStagingDir() string {
180+
dir := strings.TrimSpace(m.config.FileStagingDir)
181+
if dir == "" {
182+
dir = "./croupier-staging"
183+
}
184+
_ = os.MkdirAll(dir, 0o750)
185+
return dir
186+
}
187+
188+
// handleFilePushRequest 处理 agent → provider 的文件下发帧:
189+
// 校验通过后原子落盘(tmp+rename)到暂存目录并回确认;失败回错误说明。
190+
func (m *TCPManager) handleFilePushRequest(body []byte) ([]byte, error) {
191+
req, err := decodeFilePushRequest(body)
192+
if err != nil {
193+
return nil, fmt.Errorf("unmarshal FilePushRequest: %w", err)
194+
}
195+
196+
var transferID, storedPath, message string
197+
ok := false
198+
if validateErr := m.validateFilePush(req); validateErr != nil {
199+
message = validateErr.Error()
200+
} else {
201+
stagingDir := m.fileStagingDir()
202+
target, pathErr := safeStagingPath(stagingDir, req.fileName)
203+
if pathErr != nil {
204+
message = pathErr.Error()
205+
} else if writeErr := atomicWriteFile(target, req.data); writeErr != nil {
206+
message = fmt.Sprintf("write staging file: %v", writeErr)
207+
} else {
208+
transferID = req.transferID
209+
ok = true
210+
storedPath = target
211+
}
212+
}
213+
return encodeFilePushResponse(filePushResponse{
214+
transferID: transferID,
215+
ok: ok,
216+
storedPath: storedPath,
217+
error: message,
218+
}), nil
219+
}
220+
221+
// filePushResponse 手写编码结果。
222+
type filePushResponse struct {
223+
transferID string
224+
ok bool
225+
storedPath string
226+
error string
227+
}
228+
229+
// encodeFilePushResponse 手写 protobuf 兼容编码:
230+
// { 1: transfer_id(string), 2: ok(bool), 3: stored_path(string), 4: error(string) }。
231+
func encodeFilePushResponse(resp filePushResponse) []byte {
232+
var out []byte
233+
if resp.transferID != "" {
234+
out = append(out, 0x0A)
235+
out = appendVarint(out, uint64(len(resp.transferID)))
236+
out = append(out, resp.transferID...)
237+
}
238+
if resp.ok {
239+
out = append(out, 0x10, 0x01)
240+
}
241+
if resp.storedPath != "" {
242+
out = append(out, 0x1A)
243+
out = appendVarint(out, uint64(len(resp.storedPath)))
244+
out = append(out, resp.storedPath...)
245+
}
246+
if resp.error != "" {
247+
out = append(out, 0x22)
248+
out = appendVarint(out, uint64(len(resp.error)))
249+
out = append(out, resp.error...)
250+
}
251+
return out
252+
}
253+
254+
// atomicWriteFile 先写同目录临时文件再 rename,避免半写文件被下游读到。
255+
func atomicWriteFile(target string, data []byte) error {
256+
tmp, err := os.CreateTemp(filepath.Dir(target), ".push-*")
257+
if err != nil {
258+
return err
259+
}
260+
tmpName := tmp.Name()
261+
if _, err := tmp.Write(data); err != nil {
262+
tmp.Close()
263+
os.Remove(tmpName)
264+
return err
265+
}
266+
if err := tmp.Close(); err != nil {
267+
os.Remove(tmpName)
268+
return err
269+
}
270+
if err := os.Rename(tmpName, target); err != nil {
271+
os.Remove(tmpName)
272+
return err
273+
}
274+
return nil
275+
}

0 commit comments

Comments
 (0)