package cliprdr // file_clip.go implements the MS-RDPECLIP file transfer sequences // (§3.1.5.4.4 Copy File Sequence / §3.1.5.4.5 File Transfer Data Sequence): // // - Server → client (files copied in the remote session): the server's // Format List contains CF_HDROP; we fetch the path list via a Format // Data Request, then each file's bytes via FileContentsRequest // (FILECONTENTS_SIZE, then FILECONTENTS_RANGE chunks). // - Client → server (files staged by the local UI): staged files are // advertised as CF_HDROP in our Format List; the server fetches the // DROPFILES path list via Format Data Request, then file bytes via // FileContentsRequest, which we answer from the staged buffers. import ( "bytes" "encoding/binary" "errors" "log/slog" "unicode/utf16" ) // CF_HDROP 是标准剪贴板格式 15(文件列表)。Windows 在 short/long 两种 // 格式名模式下都按 ID 识别标准格式,名称可留空。 const CF_HDROP = 15 // FormatNameHDrop 是部分实现(FreeRDP 等)在 short name 里使用的名称。 const FormatNameHDrop = "Hdrop" // CF_DROP_EFFECT 是客户端自定的 "Preferred DropEffect" 注册格式 ID // (>0xC000 即可,线上按名称匹配)。Windows 资源管理器判定文件粘贴是否 // 可用时查询该格式;缺失或 FAIL 会导致右键菜单"粘贴"置灰。 const CF_DROP_EFFECT = 0xC0C0 // FormatNameDropEffect 是 Windows 的 DropEffect 注册格式名。 const FormatNameDropEffect = "Preferred DropEffect" // DROPEFFECT_COPY 是 DropEffect 值:我们的文件提供方式是复制。 const DROPEFFECT_COPY = 1 // MS-RDPECLIP 文件传输采用 FileGroupDescriptorW + FileContents 注册格式对 // (配合 CB_FILECLIP_NO_FILE_PATHS 能力位,mstsc 同款)。ID 客户端自定, // 服务器按我们的 Format List 中通告的 ID 回查。 const ( CF_FILE_GROUP_DESCRIPTORW = 0xC0C6 CF_FILE_CONTENTS = 0xC0C7 ) // FormatNameFileGroupDescriptorW / FormatNameFileContents 是线上的注册格式名。 const ( FormatNameFileGroupDescriptorW = "FileGroupDescriptorW" FormatNameFileContents = "FileContents" ) // fileDescriptorSize 是 FILE_DESCRIPTOR 的线上字节数 // (flags4 + clsid16 + sizl8 + pointl8 + attr4 + 3×time8 + sizeHi4 + sizeLo4 + name520)。 const fileDescriptorSize = 592 // fileDescriptorNameBytes 是 cFileName 字段长度(260 WCHAR = 520 字节)。 const fileDescriptorNameBytes = 520 // dropfilesHeaderLen 是 DROPFILES 结构长度(pFiles 4 + pt 8 + fNC 4 + fWide 4)。 const dropfilesHeaderLen = 20 // fileRangeChunk 是我方主动拉取远端文件时的每段 RANGE 大小。服务器可能 // 裁剪返回长度,接收端按"追加到累计长度"推进,不假设服务器返回整段。 const fileRangeChunk = 256 * 1024 // maxDescriptorItems 防御性上限:一份描述符列表最多 65536 项(约 38MB // 载荷),超出视为协议错位。远端复制上万文件的目录仍被接受。 const maxDescriptorItems = 65536 // maxFileTotalBytes 拒绝异常大的 SIZE 声明(>4GB 意味着协议解析已错位)。 const maxFileTotalBytes = int64(4) << 30 // LocalFile is a file staged by the local UI for client → server transfer. type LocalFile struct { Name string Data []byte } // fileStream tracks one in-flight remote → local transfer (our StreamId). type fileStream struct { index int name string total int64 got int64 buf []byte sizing bool // true until the FILECONTENTS_SIZE response arrives } // --- Public API ------------------------------------------------------------- // SetFileCallbacks attaches file clipboard callbacks (all optional): // // - onRemoteFiles is called with the file names when the server's // clipboard holds files (CF_HDROP path list received). // - onFileData is called with the assembled bytes of one file after // RequestRemoteFile finishes; data is nil when the transfer failed. // - onFileProgress is called after every received chunk. func (h *CliprdrHandler) SetFileCallbacks( onRemoteFiles func(names []string), onFileData func(index int, name string, data []byte), onFileProgress func(index int, received, total int64), ) { h.onRemoteFiles = onRemoteFiles h.onFileData = onFileData h.onFileProgress = onFileProgress } // SetLocalFiles stages files as the local (client) clipboard file content and // advertises them to the server as CF_HDROP. func (h *CliprdrHandler) SetLocalFiles(files []LocalFile) { h.mu.Lock() h.localFiles = files h.mu.Unlock() // 暂存文件是真实的本地剪贴板变更(UI 主动动作),直接重发 Format List, // 不走 suppressNextLocalChange(它属于远端回显抑制语义)。 if h.channelSender != nil { h.sendFormatList() slog.Debug("cliprdr: local files staged, sent Format List", "files", len(files)) } } // buildDropEffectReply 返回 "Preferred DropEffect" 的应答载荷 // (4 字节 DWORD DROPEFFECT_COPY)。 func buildDropEffectReply() []byte { b := make([]byte, 4) binary.LittleEndian.PutUint32(b, DROPEFFECT_COPY) return b } // BuildFileGroupDescriptorW 将暂存文件编码为 FILE_GROUP_DESCRIPTORW 载荷 // (MS-RDPECLIP 2.2.5.2.3.1):cItems + FILE_DESCRIPTOR[cItems]。 func BuildFileGroupDescriptorW(files []LocalFile) []byte { b := &bytes.Buffer{} u32 := func(v uint32) { binary.Write(b, binary.LittleEndian, v) } u32(uint32(len(files))) for _, f := range files { u32(FD_ATTRIBUTES | FD_FILESIZE | FD_PROGRESSUI) b.Write(make([]byte, 16)) // clsid b.Write(make([]byte, 8)) // sizl b.Write(make([]byte, 8)) // pointl u32(FILE_ATTRIBUTE_ARCHIVE) b.Write(make([]byte, 24)) // creation/access/write time(未设时间标志,值无效) u32(uint32(uint64(len(f.Data)) >> 32)) u32(uint32(len(f.Data))) name := make([]byte, fileDescriptorNameBytes) u16 := utf16.Encode([]rune(f.Name)) for i := 0; i < len(u16) && 2*i+1 < fileDescriptorNameBytes-1; i++ { binary.LittleEndian.PutUint16(name[2*i:], u16[i]) } b.Write(name) } return b.Bytes() } // ParseFileGroupDescriptorW 解码服务器发来的 FILE_GROUP_DESCRIPTORW, // 返回文件名与文件大小。 func ParseFileGroupDescriptorW(body []byte) ([]string, []int64, error) { if len(body) < 4 { return nil, nil, errors.New("cliprdr: FileGroupDescriptorW too short") } cItems := binary.LittleEndian.Uint32(body[0:]) // 上限只做溢出/内存炸弹防护(uint32 项数 × 592B 的乘积合法性), // 实际约束是载荷长度必须恰好容纳 cItems 个描述符——远端复制含 // 数千文件的目录是正常操作,不能按固定小数目拒绝。 if cItems > maxDescriptorItems || len(body) < 4+int(cItems)*fileDescriptorSize { return nil, nil, errors.New("cliprdr: bad FileGroupDescriptorW cItems/length") } names := make([]string, 0, cItems) sizes := make([]int64, 0, cItems) for i := 0; i < int(cItems); i++ { d := body[4+i*fileDescriptorSize : (i+1)*fileDescriptorSize] flags := binary.LittleEndian.Uint32(d[0:]) size := uint64(binary.LittleEndian.Uint32(d[68:])) | uint64(binary.LittleEndian.Uint32(d[64:]))<<32 rawName := d[72:] // 260 WCHAR UTF-16LE,NUL 结尾 end := 0 for end+1 < len(rawName) { if rawName[end] == 0 && rawName[end+1] == 0 { break } end += 2 } name := decodeUTF16LE(rawName[:end]) if flags&FD_FILESIZE == 0 { size = ^uint64(0) // 大小未知,由 FileContentsRequest(SIZE) 探测 } names = append(names, name) sizes = append(sizes, int64(size)) } return names, sizes, nil } // ClearLocalFiles removes staged files and re-advertises the Format List. func (h *CliprdrHandler) ClearLocalFiles() { h.mu.Lock() had := len(h.localFiles) > 0 h.localFiles = nil h.mu.Unlock() if had && h.channelSender != nil { h.sendFormatList() } } // RemoteFileNames returns the paths from the server's last CF_HDROP payload. func (h *CliprdrHandler) RemoteFileNames() []string { h.mu.Lock() defer h.mu.Unlock() out := make([]string, len(h.remoteFiles)) copy(out, h.remoteFiles) return out } // RequestRemoteFile starts downloading file `index` (order within the last // file list) from the server. Progress goes to onFileProgress; the // assembled bytes go to onFileData(index, name, data) — data is nil on failure. func (h *CliprdrHandler) RequestRemoteFile(index int) error { h.mu.Lock() if index < 0 || index >= len(h.remoteFiles) { h.mu.Unlock() return errors.New("cliprdr: file index out of range") } name := h.remoteFiles[index] size := int64(-1) if index < len(h.remoteFileSizes) { size = h.remoteFileSizes[index] } h.nextStreamID++ sid := h.nextStreamID h.mu.Unlock() if size >= 0 { // FileGroupDescriptorW 已声明大小:直接从 0 开始拉 RANGE h.mu.Lock() h.streams[sid] = &fileStream{index: index, name: name, total: size} h.mu.Unlock() if size == 0 { // 零字节文件:无 RANGE 可发,直接完成 h.processFileContentsResponse(u32le(sid), CB_RESPONSE_OK) return nil } h.sendFileContentsRequest(sid, uint32(index), FILECONTENTS_RANGE, 0, fileRangeChunk) slog.Debug("cliprdr: requesting remote file", "index", index, "name", name, "streamId", sid, "size", size) return nil } h.mu.Lock() h.streams[sid] = &fileStream{index: index, name: name, sizing: true} h.mu.Unlock() h.sendFileContentsRequest(sid, uint32(index), FILECONTENTS_SIZE, 0, 8) slog.Debug("cliprdr: requesting remote file", "index", index, "name", name, "streamId", sid) return nil } // --- Wire helpers ----------------------------------------------------------- // sendFileContentsRequest sends CB_FILECONTENTS_REQUEST (MS-RDPECLIP 2.2.5.2.3). func (h *CliprdrHandler) sendFileContentsRequest(streamId, lindex, dwFlags, posLow, cbRequested uint32) { b := make([]byte, 24) binary.LittleEndian.PutUint32(b[0:], streamId) binary.LittleEndian.PutUint32(b[4:], lindex) binary.LittleEndian.PutUint32(b[8:], dwFlags) binary.LittleEndian.PutUint32(b[12:], posLow) binary.LittleEndian.PutUint32(b[16:], 0) // NPositionHigh:4GB 内恒 0 binary.LittleEndian.PutUint32(b[20:], cbRequested) h.sendPDU(CB_FILECONTENTS_REQUEST, 0, b) } // processFileContentsRequest answers the server's request for staged file // bytes (client → server direction). Failure replies carry only StreamId. func (h *CliprdrHandler) processFileContentsRequest(body []byte) { if len(body) < 24 { slog.Warn("cliprdr: short FileContentsRequest", "len", len(body)) return } streamId := binary.LittleEndian.Uint32(body[0:]) lindex := binary.LittleEndian.Uint32(body[4:]) dwFlags := binary.LittleEndian.Uint32(body[8:]) pos := uint64(binary.LittleEndian.Uint32(body[12:])) | uint64(binary.LittleEndian.Uint32(body[16:]))<<32 cbRequested := binary.LittleEndian.Uint32(body[20:]) // body[24:28] 是 ClipDataId,仅在 CB_CAN_LOCK_CLIPDATA 协商后出现,此处忽略。 h.mu.Lock() files := h.localFiles h.mu.Unlock() var data []byte if lindex < uint32(len(files)) { data = files[lindex].Data } if data == nil { slog.Warn("cliprdr: FileContentsRequest for unknown file", "lindex", lindex) h.sendPDU(CB_FILECONTENTS_RESPONSE, CB_RESPONSE_FAIL, u32le(streamId)) return } var payload []byte switch { case dwFlags&FILECONTENTS_SIZE != 0: payload = make([]byte, 8) binary.LittleEndian.PutUint64(payload, uint64(len(data))) case dwFlags&FILECONTENTS_RANGE != 0: start := pos end := pos + uint64(cbRequested) if end > uint64(len(data)) || end < start { end = uint64(len(data)) } if start < uint64(len(data)) { payload = data[start:end] } default: slog.Warn("cliprdr: FileContentsRequest without SIZE/RANGE", "dwFlags", dwFlags) h.sendPDU(CB_FILECONTENTS_RESPONSE, CB_RESPONSE_FAIL, u32le(streamId)) return } out := make([]byte, 4+len(payload)) binary.LittleEndian.PutUint32(out, streamId) copy(out[4:], payload) h.sendPDU(CB_FILECONTENTS_RESPONSE, CB_RESPONSE_OK, out) slog.Debug("cliprdr: served file contents", "lindex", lindex, "flags", dwFlags, "pos", pos, "len", len(payload)) } // processFileContentsResponse consumes the server's reply for one of our // outstanding RequestRemoteFile transfers, chaining RANGE requests until the // declared size has been received. func (h *CliprdrHandler) processFileContentsResponse(body []byte, msgFlags uint16) { if len(body) < 4 { return } streamId := binary.LittleEndian.Uint32(body[0:]) data := body[4:] h.mu.Lock() st, ok := h.streams[streamId] if !ok { h.mu.Unlock() slog.Debug("cliprdr: FileContentsResponse for unknown stream", "streamId", streamId) return } fail := msgFlags&CB_RESPONSE_OK == 0 if fail { delete(h.streams, streamId) } else if st.sizing { // SIZE 响应固定 8 字节 uint64(部分实现只回 4 字节,向下兼容) if len(data) < 4 { delete(h.streams, streamId) fail = true } else { var size uint64 if len(data) >= 8 { size = binary.LittleEndian.Uint64(data) } else { size = uint64(binary.LittleEndian.Uint32(data)) } if size > uint64(maxFileTotalBytes) { slog.Warn("cliprdr: remote file size absurd", "size", size) delete(h.streams, streamId) fail = true } else { st.sizing = false st.total = int64(size) st.buf = make([]byte, 0, size) } } } else { // RANGE 响应:按实际到达长度追加(服务器允许裁剪返回段) st.buf = append(st.buf, data...) st.got = int64(len(st.buf)) } index, name, got, total := st.index, st.name, st.got, st.total requestNext := false var nextPos uint32 var finished []byte if !fail && !st.sizing { if st.got < st.total { requestNext = true nextPos = uint32(st.got) // 4GB 内;超出已由 maxFileTotalBytes 拒绝 } else { // 传输完成(含零字节文件:SIZE 后即完成)——锁内取走缓冲防竞态 finished = make([]byte, total) copy(finished, st.buf) delete(h.streams, streamId) } } h.mu.Unlock() if fail { slog.Warn("cliprdr: file transfer failed", "name", name, "streamId", streamId) if h.onFileData != nil { h.onFileData(index, name, nil) } return } if h.onFileProgress != nil { h.onFileProgress(index, got, total) } if requestNext { h.sendFileContentsRequest(streamId, uint32(index), FILECONTENTS_RANGE, nextPos, fileRangeChunk) return } if finished != nil && h.onFileData != nil { slog.Debug("cliprdr: file transfer complete", "name", name, "bytes", total) h.onFileData(index, name, finished) } } // --- DROPFILES (CF_HDROP format data, MS-RDPECLIP 2.2.5.2.4) ---------------- // ParseDropfiles decodes a CF_HDROP payload (DROPFILES header + null- // terminated path list, UTF-16LE when fWide==1, ASCII otherwise) into paths. func ParseDropfiles(body []byte) ([]string, error) { if len(body) < dropfilesHeaderLen { return nil, errors.New("cliprdr: DROPFILES body too short") } pFiles := binary.LittleEndian.Uint32(body[0:]) fWide := binary.LittleEndian.Uint32(body[16:]) if pFiles < dropfilesHeaderLen || int(pFiles) > len(body) { return nil, errors.New("cliprdr: bad DROPFILES pFiles offset") } list := body[pFiles:] var names []string if fWide == 1 { // 双 NUL 结尾的 UTF-16LE 路径列表 start := 0 for i := 0; i+1 < len(list); i += 2 { if list[i] == 0 && list[i+1] == 0 { if i == start { // 连续两个 NUL = 列表结束 break } names = append(names, decodeUTF16LE(list[start:i])) start = i + 2 } } } else { start := 0 for i := 0; i < len(list); i++ { if list[i] == 0 { if i == start { break } names = append(names, string(list[start:i])) start = i + 1 } } } return names, nil } // BuildDropfiles encodes names into a CF_HDROP payload (UTF-16LE, fWide=1). func BuildDropfiles(names []string) []byte { b := &bytes.Buffer{} u32 := func(v uint32) { binary.Write(b, binary.LittleEndian, v) } u32(dropfilesHeaderLen) // pFiles u32(0) // pt.x u32(0) // pt.y u32(0) // fNC u32(1) // fWide = Unicode for _, n := range names { b.Write(encodeUTF16LE(n)) b.Write([]byte{0, 0}) // 路径 NUL 结尾 } b.Write([]byte{0, 0}) // 列表终止双 NUL return b.Bytes() } // u32le returns v as a 4-byte little-endian slice. func u32le(v uint32) []byte { b := make([]byte, 4) binary.LittleEndian.PutUint32(b, v) return b }