init: 自 zomaintain/backend/rdplib 平移独立成库; module path 从上游 github.com/nakagami/grdp 改为 git.zeroonesoft.cn/golib/rdplib

This commit is contained in:
w11
2026-09-19 18:55:43 +08:00
commit 1096fbce9d
94 changed files with 36790 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
package cliprdr
import (
"bytes"
"fmt"
"log/slog"
"unicode/utf16"
"github.com/lunixbochs/struc"
"git.zeroonesoft.cn/golib/rdplib/core"
)
type CliprdrClient struct {
w core.ChannelSender
useLongFormatNames bool
streamFileClipEnabled bool
fileClipNoFilePaths bool
canLockClipData bool
hasHugeFileSupport bool
formatIdMap map[uint32]uint32
reply chan []byte
}
func NewCliprdrClient() *CliprdrClient {
c := &CliprdrClient{
formatIdMap: make(map[uint32]uint32, 20),
reply: make(chan []byte, 100),
}
go ClipWatcher(c)
return c
}
func (c *CliprdrClient) Sender(f core.ChannelSender) {
c.w = f
}
func (c *CliprdrClient) GetType() (string, uint32) {
return ChannelName, ChannelOption
}
func (c *CliprdrClient) Process(s []byte) {
r := bytes.NewReader(s)
msgType, _ := core.ReadUint16LE(r)
flag, _ := core.ReadUint16LE(r)
length, _ := core.ReadUInt32LE(r)
slog.Debug(fmt.Sprintf("cliprdr: type=0x%x flag=%d length=%d, all=%d", msgType, flag, length, r.Len()))
b, _ := core.ReadBytes(int(length), r)
switch msgType {
case CB_CLIP_CAPS:
slog.Debug("CB_CLIP_CAPS")
c.processClipCaps(b)
case CB_MONITOR_READY:
slog.Debug("CB_MONITOR_READY")
c.processMonitorReady(b)
case CB_FORMAT_LIST:
slog.Debug("CB_FORMAT_LIST")
c.processFormatList(b)
case CB_FORMAT_LIST_RESPONSE:
slog.Debug("CB_FORMAT_LIST_RESPONSE")
c.processFormatListResponse(flag, b)
case CB_FORMAT_DATA_REQUEST:
slog.Debug("CB_FORMAT_DATA_REQUEST")
c.processFormatDataRequest(b)
case CB_FORMAT_DATA_RESPONSE:
slog.Debug("CB_FORMAT_DATA_RESPONSE")
c.processFormatDataResponse(flag, b)
case CB_FILECONTENTS_REQUEST:
slog.Debug("CB_FILECONTENTS_REQUEST")
c.processFileContentsRequest(b)
case CB_FILECONTENTS_RESPONSE:
slog.Debug("CB_FILECONTENTS_RESPONSE")
c.processFileContentsResponse(flag, b)
case CB_LOCK_CLIPDATA:
slog.Debug("CB_LOCK_CLIPDATA")
c.processLockClipData(b)
case CB_UNLOCK_CLIPDATA:
slog.Debug("CB_UNLOCK_CLIPDATA")
c.processUnlockClipData(b)
default:
slog.Error(fmt.Sprintf("type 0x%x not supported", msgType))
}
}
func (c *CliprdrClient) processClipCaps(b []byte) {
r := bytes.NewReader(b)
var cp CliprdrCapabilitiesPDU
err := struc.Unpack(r, &cp)
if err != nil {
slog.Error("Failed to unpack", "error", err)
return
}
slog.Debug(fmt.Sprintf("Capabilities:%+v", cp))
c.useLongFormatNames = cp.CapabilitySets[0].GeneralFlags&CB_USE_LONG_FORMAT_NAMES != 0
c.streamFileClipEnabled = cp.CapabilitySets[0].GeneralFlags&CB_STREAM_FILECLIP_ENABLED != 0
c.fileClipNoFilePaths = cp.CapabilitySets[0].GeneralFlags&CB_FILECLIP_NO_FILE_PATHS != 0
c.canLockClipData = cp.CapabilitySets[0].GeneralFlags&CB_CAN_LOCK_CLIPDATA != 0
c.hasHugeFileSupport = cp.CapabilitySets[0].GeneralFlags&CB_HUGE_FILE_SUPPORT_ENABLED != 0
slog.Debug("UseLongFormatNames", "value", c.useLongFormatNames)
slog.Debug("StreamFileClipEnabled", "value", c.streamFileClipEnabled)
slog.Debug("FileClipNoFilePaths", "value", c.fileClipNoFilePaths)
slog.Debug("CanLockClipData", "value", c.canLockClipData)
slog.Debug("HasHugeFileSupport", "value", c.hasHugeFileSupport)
}
func (c *CliprdrClient) processMonitorReady(b []byte) {
c.sendClientCapabilitiesPDU()
c.sendFormatListPDU()
}
func (c *CliprdrClient) processFormatList(b []byte) {
EmptyClipboard()
fl, _ := c.readFormatList(b)
slog.Debug("numFormats", "count", fl.NumFormats)
c.sendFormatListResponse(CB_RESPONSE_OK)
}
func (c *CliprdrClient) processFormatListResponse(flag uint16, b []byte) {
if flag != CB_RESPONSE_OK {
slog.Error("Format List Response Failed")
return
}
slog.Debug("Format List Response OK")
}
func (c *CliprdrClient) processFormatDataRequest(b []byte) {
r := bytes.NewReader(b)
_, _ = core.ReadUInt32LE(r) // requestId
buff := &bytes.Buffer{}
// Text-only: directly get clipboard data for any text format
data := GetClipboardText()
slog.Debug("clipboard data", "content", data)
buff.Write(core.UnicodeEncode(data))
buff.Write([]byte{0, 0})
c.sendFormatDataResponse(buff.Bytes())
}
func (c *CliprdrClient) processFormatDataResponse(flag uint16, b []byte) {
if flag != CB_RESPONSE_OK {
slog.Error("Format Data Response Failed")
}
c.reply <- b
}
func (c *CliprdrClient) processFileContentsRequest(b []byte) {
// Text-only mode doesn't support file transfer
slog.Debug("File transfer not supported in text-only mode")
}
func (c *CliprdrClient) processFileContentsResponse(flag uint16, b []byte) {
// Text-only mode doesn't support file transfer
}
func (c *CliprdrClient) processLockClipData(b []byte) {
r := bytes.NewReader(b)
var l CliprdrCtrlClipboardData
l.ClipDataId, _ = core.ReadUInt32LE(r)
}
func (c *CliprdrClient) processUnlockClipData(b []byte) {
r := bytes.NewReader(b)
var l CliprdrCtrlClipboardData
l.ClipDataId, _ = core.ReadUInt32LE(r)
}
func (c *CliprdrClient) sendClientCapabilitiesPDU() {
slog.Debug("Send Client Clipboard Capabilities PDU (text-only mode)")
var cs CliprdrGeneralCapabilitySet
cs.CapabilitySetLength = 12
cs.CapabilitySetType = CB_CAPSTYPE_GENERAL
cs.Version = CB_CAPS_VERSION_2
// Text-only mode: only use long format names
cs.GeneralFlags = CB_USE_LONG_FORMAT_NAMES
body := &bytes.Buffer{}
core.WriteUInt16LE(1, body) // cCapabilitiesSets
core.WriteUInt16LE(0, body) // pad
struc.Pack(body, cs)
sendClipPDU(c.w, CB_CLIP_CAPS, 0, body.Bytes())
}
func (c *CliprdrClient) sendTemporaryDirectoryPDU() {
slog.Debug("Send Temporary Directory PDU (ignored in text-only mode)")
}
func (c *CliprdrClient) sendFormatListPDU() {
slog.Debug("Send Format List PDU (text formats only)")
formats := GetFormatList()
slog.Debug("available formats", "count", len(formats), "formats", formats)
body := &bytes.Buffer{}
for _, v := range formats {
core.WriteUInt32LE(v.FormatId, body)
if v.FormatName == "" {
core.WriteUInt16LE(0, body)
} else {
n := core.UnicodeEncode(v.FormatName)
core.WriteBytes(n, body)
body.Write([]byte{0, 0})
}
}
sendClipPDU(c.w, CB_FORMAT_LIST, 0, body.Bytes())
}
func (c *CliprdrClient) readFormatList(b []byte) (*CliprdrFormatList, bool) {
r := bytes.NewReader(b)
fs := make([]CliprdrFormat, 0, 20)
var numFormats uint32 = 0
c.formatIdMap = make(map[uint32]uint32, 0)
for r.Len() > 0 {
formatId, _ := core.ReadUInt32LE(r)
bs := make([]uint16, 0, 20)
ln := r.Len()
for range ln {
b, _ := core.ReadUint16LE(r)
if b == 0 {
break
}
bs = append(bs, b)
}
name := string(utf16.Decode(bs))
slog.Debug(fmt.Sprintf("Format:%d Name:<%s>", formatId, name))
if name != "" {
localId := RegisterClipboardFormat(name)
slog.Debug("format mapping", "local", localId, "remote", formatId)
c.formatIdMap[localId] = formatId
} else {
c.formatIdMap[formatId] = formatId
}
numFormats++
fs = append(fs, CliprdrFormat{formatId, name})
}
return &CliprdrFormatList{numFormats, fs}, false
}
func (c *CliprdrClient) sendFormatListResponse(flags uint16) {
slog.Debug("Send Format List Response")
sendClipPDU(c.w, CB_FORMAT_LIST_RESPONSE, flags, nil)
}
func (c *CliprdrClient) sendFormatDataRequest(id uint32) {
slog.Debug("Send Format Data Request")
body := &bytes.Buffer{}
core.WriteUInt32LE(id, body)
sendClipPDU(c.w, CB_FORMAT_DATA_REQUEST, 0, body.Bytes())
}
func (c *CliprdrClient) sendFormatDataResponse(b []byte) {
slog.Debug("Send Format Data Response")
sendClipPDU(c.w, CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, b)
}
+181
View File
@@ -0,0 +1,181 @@
// Generic, OS-independent clipboard support with text format only
package cliprdr
import (
"log/slog"
)
// SimpleTextClipboard provides OS-independent text-only clipboard support
type SimpleTextClipboard struct {
textContent string
}
var textClipboard = &SimpleTextClipboard{}
// GetClipboardText returns the current text content
func GetClipboardText() string {
return textClipboard.textContent
}
// SetClipboardText sets the clipboard text content
func SetClipboardText(text string) {
textClipboard.textContent = text
slog.Debug("Text clipboard set", "length", len(text))
}
// GetFormatList returns available formats (text only in generic mode)
func GetFormatList() []CliprdrFormat {
formatId := uint32(CF_UNICODETEXT)
formats := make([]CliprdrFormat, 0, 1)
formats = append(formats, CliprdrFormat{
FormatId: formatId,
FormatName: "CF_UNICODETEXT",
})
return formats
}
// ClipWatcher is a no-op in generic mode
func ClipWatcher(c *CliprdrClient) {
slog.Debug("Generic clipboard watcher (text-only mode) started")
// In generic mode, we don't actively monitor system clipboard
// Format list is provided statically
select {} // Block indefinitely
}
// Stub functions for compatibility (no-op in generic mode)
func OpenClipboard(hwnd uintptr) bool {
return true
}
func CloseClipboard() bool {
return true
}
func CountClipboardFormats() int32 {
return 1
}
func IsClipboardFormatAvailable(id uint32) bool {
return id == CF_UNICODETEXT || id == CF_TEXT
}
func EnumClipboardFormats(formatId uint32) uint32 {
if formatId == 0 {
return CF_UNICODETEXT
}
return 0
}
func GetClipboardFormatName(id uint32) string {
switch id {
case CF_TEXT:
return "CF_TEXT"
case CF_UNICODETEXT:
return "CF_UNICODETEXT"
default:
return ""
}
}
func EmptyClipboard() bool {
textClipboard.textContent = ""
return true
}
func RegisterClipboardFormat(format string) uint32 {
// Simple hash-based format ID generation
sum := uint32(0)
for _, c := range format {
sum = sum*31 + uint32(c)
}
return sum | 0xC000 // Set bit 14 for custom formats
}
func IsClipboardOwner(h uintptr) bool {
return false // Not applicable in generic mode
}
func HmemAlloc(data []byte) uintptr {
// In generic mode, return a pointer to the data
if len(data) == 0 {
return 0
}
return uintptr(len(data))
}
func SetClipboardData(formatId uint32, hmem uintptr) bool {
// No-op in generic mode
return true
}
func GetClipboardData(formatId uint32) string {
if formatId == CF_UNICODETEXT || formatId == CF_TEXT {
return GetClipboardText()
}
return ""
}
func GlobalSize(hMem uintptr) uintptr {
return hMem
}
func GlobalLock(hMem uintptr) uintptr {
return hMem
}
func GlobalUnlock(hMem uintptr) {
// No-op
}
func OleGetClipboard() *IDataObject {
return nil
}
func OleSetClipboard(dataObject *IDataObject) bool {
return true
}
func OleIsCurrentClipboard(dataObject *IDataObject) bool {
return true
}
// GetFileNames and GetFileInfo are not supported in text-only generic mode.
func GetFileNames() []string {
return []string{}
}
func GetFileInfo(sys any) (uint32, []byte, uint32, uint32) {
return 0, []byte{}, 0, 0
}
// IDataObject stub - not used in text-only mode
type IDataObject struct {
ptr uintptr
}
type IUnknown struct {
ptr uintptr
}
type FORMATETC struct {
CFormat uint32
DvTargetDevice uintptr
Aspect uint32
Index int32
Tymed uint32
}
type STGMEDIUM struct {
Tymed uint32
UnionMember uintptr
PUnkForRelease *IUnknown
}
func (s *STGMEDIUM) Bytes() ([]byte, error) {
return []byte{}, nil
}
func CreateDataObject(c *CliprdrClient) *IDataObject {
return nil
}
+198
View File
@@ -0,0 +1,198 @@
// cliprdr_image_test.go — 剪贴板图片链路的协议层单元测试(native 可跑)
package cliprdr_test
import (
"bytes"
"encoding/binary"
"image/png"
"sync"
"testing"
"git.zeroonesoft.cn/golib/rdplib/core"
"git.zeroonesoft.cn/golib/rdplib/plugin/cliprdr"
)
// captureSender 捕获 handler 发出的 PDU,供断言
type captureSender struct {
mu sync.Mutex
pdus [][]byte
}
func (s *captureSender) GetType() (string, uint32) { return "cliprdr", 0 }
func (s *captureSender) Sender(f core.ChannelSender) {}
func (s *captureSender) Process(data []byte) {}
func (s *captureSender) SendToChannel(name string, data []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
pdu := make([]byte, len(data))
copy(pdu, data)
s.pdus = append(s.pdus, pdu)
return len(data), nil
}
func (s *captureSender) findByType(msgType uint16) [][]byte {
s.mu.Lock()
defer s.mu.Unlock()
var out [][]byte
for _, p := range s.pdus {
if len(p) >= 8 && binary.LittleEndian.Uint16(p[0:]) == msgType {
out = append(out, p)
}
}
return out
}
// fakeDIB 构造一张 2x2 的 32bpp BI_RGB 底行优先 DIB
func fakeDIB() []byte {
const w, h = 2, 2
dib := make([]byte, 40+w*4*h)
binary.LittleEndian.PutUint32(dib[0:4], 40) // biSize
binary.LittleEndian.PutUint32(dib[4:8], w) // width
binary.LittleEndian.PutUint32(dib[8:12], h) // height(正 = 底行优先)
binary.LittleEndian.PutUint16(dib[12:14], 1) // planes
binary.LittleEndian.PutUint16(dib[14:16], 32) // bpp
pix := dib[40:]
// 视觉顶部 = DIB 最后一行:红、绿
off := (h - 1) * w * 4
binary.LittleEndian.PutUint32(pix[off:off+4], 0x00FF0000) // BGRA → 红
binary.LittleEndian.PutUint32(pix[off+4:off+8], 0x0000FF00) // 绿
// 视觉底部 = DIB 首行:蓝、白
binary.LittleEndian.PutUint32(pix[0:4], 0x000000FF)
binary.LittleEndian.PutUint32(pix[4:8], 0x00FFFFFF)
return dib
}
// capsPDU 构造 CB_CLIP_CAPS(声明长格式名),handler 需先收到它才会按长名解析
func capsPDU() []byte {
body := make([]byte, 0, 20)
body = binary.LittleEndian.AppendUint16(body, 1) // cCapSets
body = binary.LittleEndian.AppendUint16(body, 0) // pad
body = binary.LittleEndian.AppendUint16(body, cliprdr.CB_CAPSTYPE_GENERAL)
body = binary.LittleEndian.AppendUint16(body, cliprdr.CB_CAPSTYPE_GENERAL_LEN)
body = binary.LittleEndian.AppendUint32(body, cliprdr.CB_CAPS_VERSION_2)
body = binary.LittleEndian.AppendUint32(body, cliprdr.CB_USE_LONG_FORMAT_NAMES)
p := pduHeader(cliprdr.CB_CLIP_CAPS, 0, len(body))
return append(p, body...)
}
func TestDIBToPNG(t *testing.T) {
pngBytes, err := cliprdr.DIBToPNG(fakeDIB())
if err != nil {
t.Fatalf("DIBToPNG: %v", err)
}
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
t.Fatalf("decoded PNG invalid: %v", err)
}
if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 2 {
t.Fatalf("unexpected size %v", img.Bounds())
}
r, g, b, _ := img.At(0, 0).RGBA() // RGBA() 分量为 16 位
if r != 0xFFFF || g != 0 || b != 0 {
t.Fatalf("pixel(0,0) want red, got r=%x g=%x b=%x", r, g, b)
}
}
func TestFormatDataResponseDIBConvertedToPNG(t *testing.T) {
h := cliprdr.NewHandler(nil, nil)
sender := &captureSender{}
h.Sender(sender)
var gotPNG []byte
h.SetImageCallbacks(func(p []byte) { gotPNG = p }, nil)
// 先声明能力(长格式名),再让服务端提供仅含 CF_DIB 的 FormatList
h.Process(capsPDU())
h.Process(mkFormatListLong([]cliprdr.CliprdrFormat{{FormatId: cliprdr.CF_DIB}}))
h.Process(responsePDU(cliprdr.CF_DIB, fakeDIB()))
if len(gotPNG) == 0 {
t.Fatal("expected PNG bytes via onRemoteClipboardImage")
}
if _, err := png.Decode(bytes.NewReader(gotPNG)); err != nil {
t.Fatalf("converted PNG invalid: %v", err)
}
}
func TestFormatListPNGTriggersRequest(t *testing.T) {
h := cliprdr.NewHandler(nil, nil)
sender := &captureSender{}
h.Sender(sender)
h.Process(capsPDU())
h.Process(mkFormatListLong([]cliprdr.CliprdrFormat{
{FormatId: 0xC3F0, FormatName: "PNG"},
{FormatId: cliprdr.CF_UNICODETEXT, FormatName: ""},
}))
requests := sender.findByType(0x0004) // CB_FORMAT_DATA_REQUEST
for _, p := range requests {
id := binary.LittleEndian.Uint32(p[8:12])
if id == 0xC3F0 { // 应按服务端给出的 PNG 注册 ID 发起请求
return
}
}
t.Fatal("expected PNG format data request for server-offered PNG format")
}
func TestFormatDataRequestPNGServedFromProvider(t *testing.T) {
h := cliprdr.NewHandler(nil, nil)
sender := &captureSender{}
h.Sender(sender)
h.SetImageCallbacks(nil, func() []byte { return []byte("FAKEPNG") })
h.Process(requestPDU(cliprdr.CF_PNG))
responses := sender.findByType(0x0005) // CB_FORMAT_DATA_RESPONSE
for _, p := range responses {
if binary.LittleEndian.Uint16(p[2:]) == 1 && bytes.Equal(p[8:15], []byte("FAKEPNG")) {
return
}
}
t.Fatalf("expected PNG data served from provider, got %d responses", len(responses))
}
// --- helpers: 构造标准 CLIPRDR PDU ---
func pduHeader(msgType, msgFlags uint16, bodyLen int) []byte {
h := make([]byte, 8)
binary.LittleEndian.PutUint16(h[0:], msgType)
binary.LittleEndian.PutUint16(h[2:], msgFlags)
binary.LittleEndian.PutUint32(h[4:], uint32(bodyLen))
return h
}
func requestPDU(formatId uint32) []byte {
p := pduHeader(0x0004, 0, 4)
return binary.LittleEndian.AppendUint32(p, formatId)
}
func responsePDU(formatId uint32, data []byte) []byte {
_ = formatId // 响应 PDU 本身不含格式 ID,格式由我方请求记录
p := pduHeader(0x0005, 1, len(data))
return append(p, data...)
}
func mkFormatListLong(formats []cliprdr.CliprdrFormat) []byte {
var body bytes.Buffer
for _, f := range formats {
binary.Write(&body, binary.LittleEndian, f.FormatId)
body.Write(encodeUTF16(f.FormatName))
body.Write([]byte{0, 0})
}
p := pduHeader(0x0002, 0, body.Len())
return append(p, body.Bytes()...)
}
func encodeUTF16(s string) []byte {
u := []uint16{}
for _, r := range s {
u = append(u, uint16(r))
}
out := make([]byte, len(u)*2)
for i, v := range u {
binary.LittleEndian.PutUint16(out[i*2:], v)
}
return out
}
+44
View File
@@ -0,0 +1,44 @@
// cliprdr_test.go
package cliprdr_test
import (
"fmt"
"testing"
"git.zeroonesoft.cn/golib/rdplib/plugin/cliprdr"
)
func TestClipboardText(t *testing.T) {
// Test text clipboard functionality
testText := "Hello, Clipboard!"
cliprdr.SetClipboardText(testText)
result := cliprdr.GetClipboardText()
if result != testText {
t.Errorf("Expected %s, got %s", testText, result)
}
fmt.Printf("Text clipboard test passed: %s\n", result)
}
func TestFormatList(t *testing.T) {
// Test format list (should contain only text format)
formats := cliprdr.GetFormatList()
if len(formats) == 0 {
t.Error("Format list should not be empty")
}
fmt.Printf("Formats: %v\n", formats)
}
func TestOpenClipboard(t *testing.T) {
// Test OpenClipboard (no-op in generic mode)
ok := cliprdr.OpenClipboard(0)
if !ok {
t.Error("OpenClipboard should return true in generic mode")
}
ok = cliprdr.CloseClipboard()
if !ok {
t.Error("CloseClipboard should return true in generic mode")
}
fmt.Println("OpenClipboard test passed")
}
+327
View File
@@ -0,0 +1,327 @@
package cliprdr
import (
"bytes"
"github.com/lunixbochs/struc"
"git.zeroonesoft.cn/golib/rdplib/core"
"git.zeroonesoft.cn/golib/rdplib/plugin"
)
/**
* Initialization Sequence\n
* Client Server\n
* | |\n
* |<----------------------Server Clipboard Capabilities PDU-----------------|\n
* |<-----------------------------Monitor Ready PDU--------------------------|\n
* |-----------------------Client Clipboard Capabilities PDU---------------->|\n
* |---------------------------Temporary Directory PDU---------------------->|\n
* |-------------------------------Format List PDU-------------------------->|\n
* |<--------------------------Format List Response PDU----------------------|\n
*
*/
/**
* Data Transfer Sequences\n
* Shared Local\n
* Clipboard Owner Clipboard Owner\n
* | |\n
* |-------------------------------------------------------------------------|\n _
* |-------------------------------Format List PDU-------------------------->|\n |
* |<--------------------------Format List Response PDU----------------------|\n _| Copy
* Sequence
* |<---------------------Lock Clipboard Data PDU (Optional)-----------------|\n
* |-------------------------------------------------------------------------|\n
* |-------------------------------------------------------------------------|\n _
* |<--------------------------Format Data Request PDU-----------------------|\n | Paste
* Sequence Palette,
* |---------------------------Format Data Response PDU--------------------->|\n _| Metafile,
* File List Data
* |-------------------------------------------------------------------------|\n
* |-------------------------------------------------------------------------|\n _
* |<------------------------Format Contents Request PDU---------------------|\n | Paste
* Sequence
* |-------------------------Format Contents Response PDU------------------->|\n _| File
* Stream Data
* |<---------------------Lock Clipboard Data PDU (Optional)-----------------|\n
* |-------------------------------------------------------------------------|\n
*
*/
const (
ChannelName = plugin.CLIPRDR_SVC_CHANNEL_NAME
ChannelOption = plugin.CHANNEL_OPTION_INITIALIZED | plugin.CHANNEL_OPTION_ENCRYPT_RDP |
plugin.CHANNEL_OPTION_COMPRESS_RDP | plugin.CHANNEL_OPTION_SHOW_PROTOCOL
)
type MsgType uint16
const (
CB_MONITOR_READY = 0x0001
CB_FORMAT_LIST = 0x0002
CB_FORMAT_LIST_RESPONSE = 0x0003
CB_FORMAT_DATA_REQUEST = 0x0004
CB_FORMAT_DATA_RESPONSE = 0x0005
CB_TEMP_DIRECTORY = 0x0006
CB_CLIP_CAPS = 0x0007
CB_FILECONTENTS_REQUEST = 0x0008
CB_FILECONTENTS_RESPONSE = 0x0009
CB_LOCK_CLIPDATA = 0x000A
CB_UNLOCK_CLIPDATA = 0x000B
)
type MsgFlags uint16
const (
CB_RESPONSE_OK = 0x0001
CB_RESPONSE_FAIL = 0x0002
CB_ASCII_NAMES = 0x0004
)
type DwFlags uint32
const (
FILECONTENTS_SIZE = 0x00000001
FILECONTENTS_RANGE = 0x00000002
)
type CliprdrPDUHeader struct {
MsgType uint16 `struc:"little"`
MsgFlags uint16 `struc:"little"`
DataLen uint32 `struc:"little"`
}
func NewCliprdrPDUHeader(mType, flags uint16, ln uint32) *CliprdrPDUHeader {
return &CliprdrPDUHeader{
MsgType: mType,
MsgFlags: flags,
DataLen: ln,
}
}
func (h *CliprdrPDUHeader) serialize() []byte {
b := &bytes.Buffer{}
core.WriteUInt16LE(h.MsgType, b)
core.WriteUInt16LE(h.MsgFlags, b)
core.WriteUInt32LE(h.DataLen, b)
return b.Bytes()
}
type CliprdrGeneralCapabilitySet struct {
CapabilitySetType uint16 `struc:"little"`
CapabilitySetLength uint16 `struc:"little"`
Version uint32 `struc:"little"`
GeneralFlags uint32 `struc:"little"`
}
const (
CB_CAPSTYPE_GENERAL = 0x0001
)
type CliprdrCapabilitySets struct {
CapabilitySetType uint16 `struc:"little"`
LengthCapability uint16 `struc:"little"`
Version uint32 `struc:"little"`
GeneralFlags uint32 `struc:"little"`
}
type CliprdrCapabilitiesPDU struct {
CCapabilitiesSets uint16 `struc:"little,sizeof=CapabilitySets"`
Pad1 uint16 `struc:"little"`
CapabilitySets []CliprdrGeneralCapabilitySet `struc:"little"`
}
type CliprdrMonitorReady struct {
}
type GeneralFlags uint32
const (
CB_USE_LONG_FORMAT_NAMES = 0x00000002
CB_STREAM_FILECLIP_ENABLED = 0x00000004
CB_FILECLIP_NO_FILE_PATHS = 0x00000008
CB_CAN_LOCK_CLIPDATA = 0x00000010
CB_HUGE_FILE_SUPPORT_ENABLED = 0x00000020
)
const (
CB_CAPS_VERSION_1 = 0x00000001
CB_CAPS_VERSION_2 = 0x00000002
)
const (
CB_CAPSTYPE_GENERAL_LEN = 12
)
const (
FD_CLSID = 0x00000001
FD_SIZEPOINT = 0x00000002
FD_ATTRIBUTES = 0x00000004
FD_CREATETIME = 0x00000008
FD_ACCESSTIME = 0x00000010
FD_WRITESTIME = 0x00000020
FD_FILESIZE = 0x00000040
FD_PROGRESSUI = 0x00004000
FD_LINKUI = 0x00008000
)
const (
FILE_ATTRIBUTE_DIRECTORY = 0x00000010
FILE_ATTRIBUTE_ARCHIVE = 0x00000020
)
// fileDescriptorZero is a reusable zero block for FileDescriptor padding writes.
var fileDescriptorZero [512]byte
type FileGroupDescriptor struct {
CItems uint32 `struc:"little"`
Fgd []FileDescriptor `struc:"sizefrom=CItems"`
}
type FileDescriptor struct {
Flags uint32 `struc:"little"`
Clsid [16]byte `struc:"little"`
Sizel [8]byte `struc:"little"`
Pointl [8]byte `struc:"little"`
FileAttributes uint32 `struc:"little"`
CreationTime [8]byte `struc:"little"`
LastAccessTime [8]byte `struc:"little"`
LastWriteTime []byte `struc:"[8]byte"` //8
FileSizeHigh uint32 `struc:"little"`
FileSizeLow uint32 `struc:"little"`
FileName []byte `struc:"[512]byte"`
}
func (f *FileGroupDescriptor) Unpack(b []byte) error {
r := bytes.NewReader(b)
return struc.Unpack(r, f)
}
func (f *FileDescriptor) serialize() []byte {
b := &bytes.Buffer{}
core.WriteUInt32LE(f.Flags, b)
b.Write(fileDescriptorZero[:32])
core.WriteUInt32LE(f.FileAttributes, b)
b.Write(fileDescriptorZero[:16])
core.WriteBytes(f.LastWriteTime[:], b)
core.WriteUInt32LE(f.FileSizeHigh, b)
core.WriteUInt32LE(f.FileSizeLow, b)
b.Write(f.FileName)
if pad := 512 - len(f.FileName); pad > 0 {
b.Write(fileDescriptorZero[:pad])
}
return b.Bytes()
}
func (f *FileDescriptor) isDir() bool {
if f.Flags&FD_ATTRIBUTES != 0 {
return f.FileAttributes&FILE_ATTRIBUTE_DIRECTORY != 0
}
return false
}
func (f *FileDescriptor) hasFileSize() bool {
return f.Flags&FD_FILESIZE != 0
}
// temp dir
type CliprdrTempDirectory struct {
SzTempDir []byte `struc:"[260]byte"`
}
// format list
type CliprdrFormat struct {
FormatId uint32
FormatName string
}
type CliprdrFormatList struct {
NumFormats uint32
Formats []CliprdrFormat
}
type ClipboardFormats uint16
const (
CB_FORMAT_HTML = 0xD010
CB_FORMAT_PNG = 0xD011
CB_FORMAT_JPEG = 0xD012
CB_FORMAT_GIF = 0xD013
CB_FORMAT_TEXTURILIST = 0xD014
CB_FORMAT_GNOMECOPIEDFILES = 0xD015
CB_FORMAT_MATECOPIEDFILES = 0xD016
)
// Standard clipboard format IDs
const (
CF_TEXT = 1
CF_DIB = 8
CF_UNICODETEXT = 13
CF_DIBV5 = 17
// CF_PNG 是客户端自定义的注册格式 ID。注册格式在线上按"名称"匹配,
// Windows 侧对应 RegisterClipboardFormat("PNG"),ID 仅需 >0xC000 且客户端自定。
CF_PNG = 0xC0BC
// CF_HTML_FORMAT_ID 是客户端自定的 "HTML Format" 注册格式 ID(同上)。
CF_HTML_FORMAT_ID = 0xC0BE
)
// FormatNamePNG 是 Windows 剪贴板的 PNG 注册格式名
const FormatNamePNG = "PNG"
// FormatNameHTML 是 Windows 剪贴板的 HTML 注册格式名(MS-Doc: HTML Clipboard Format)
const FormatNameHTML = "HTML Format"
// lock or unlock
type CliprdrCtrlClipboardData struct {
ClipDataId uint32
}
// format data
type CliprdrFormatDataRequest struct {
RequestedFormatId uint32
}
type CliprdrFormatDataResponse struct {
RequestedFormatData []byte
}
// file contents
type CliprdrFileContentsRequest struct {
StreamId uint32 `struc:"little"`
Lindex uint32 `struc:"little"`
DwFlags uint32 `struc:"little"`
NPositionLow uint32 `struc:"little"`
NPositionHigh uint32 `struc:"little"`
CbRequested uint32 `struc:"little"`
ClipDataId uint32 `struc:"little"`
}
func FileContentsSizeRequest(i uint32) *CliprdrFileContentsRequest {
return &CliprdrFileContentsRequest{
StreamId: 1,
Lindex: i,
DwFlags: FILECONTENTS_SIZE,
NPositionLow: 0,
NPositionHigh: 0,
CbRequested: 65535,
ClipDataId: 0,
}
}
type CliprdrFileContentsResponse struct {
StreamId uint32
CbRequested uint32
RequestedData []byte
}
func (resp *CliprdrFileContentsResponse) Unpack(b []byte) {
r := bytes.NewReader(b)
resp.StreamId, _ = core.ReadUInt32LE(r)
resp.CbRequested = uint32(r.Len())
resp.RequestedData, _ = core.ReadBytes(int(resp.CbRequested), r)
}
// sendClipPDU sends a CLIPRDR PDU with the standard 8-byte header
// (msgType + msgFlags + dataLen) prepended to body.
func sendClipPDU(sender core.ChannelSender, msgType, msgFlags uint16, body []byte) {
b := &bytes.Buffer{}
core.WriteUInt16LE(msgType, b)
core.WriteUInt16LE(msgFlags, b)
core.WriteUInt32LE(uint32(len(body)), b)
b.Write(body)
sender.SendToChannel(ChannelName, b.Bytes())
}
+479
View File
@@ -0,0 +1,479 @@
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
}
+527
View File
@@ -0,0 +1,527 @@
package cliprdr
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
)
// capturedPDU 是 fakeSender 捕获的一条完整 CLIPRDR PDU。
type capturedPDU struct {
msgType uint16
flags uint16
body []byte
}
// fakeSender 捕获所有发出通道的数据,供断言。
type fakeSender struct{ pdus []capturedPDU }
func (f *fakeSender) SendToChannel(_ string, data []byte) (int, error) {
cp := make([]byte, len(data))
copy(cp, data)
if len(cp) < 8 {
return len(cp), nil
}
f.pdus = append(f.pdus, capturedPDU{
msgType: binary.LittleEndian.Uint16(cp[0:]),
flags: binary.LittleEndian.Uint16(cp[2:]),
body: cp[8:],
})
return len(cp), nil
}
func (f *fakeSender) last() capturedPDU { return f.pdus[len(f.pdus)-1] }
// newFileTestHandler 构造带 fakeSender 且 serverFileClip=true 的 handler
// (模拟服务器 caps 已声明 CB_STREAM_FILECLIP_ENABLED)。
func newFileTestHandler(t *testing.T) (*CliprdrHandler, *fakeSender) {
t.Helper()
h := NewHandler(nil, nil)
fs := &fakeSender{}
h.Sender(fs)
caps := &bytes.Buffer{}
binary.Write(caps, binary.LittleEndian, uint16(1)) // cCapSets
binary.Write(caps, binary.LittleEndian, uint16(0)) // pad
binary.Write(caps, binary.LittleEndian, uint16(CB_CAPSTYPE_GENERAL))
binary.Write(caps, binary.LittleEndian, uint16(12))
binary.Write(caps, binary.LittleEndian, uint32(CB_CAPS_VERSION_2))
binary.Write(caps, binary.LittleEndian, uint32(CB_USE_LONG_FORMAT_NAMES|CB_STREAM_FILECLIP_ENABLED))
h.processClipCaps(caps.Bytes())
if !h.serverFileClip {
t.Fatal("serverFileClip should be set after caps with CB_STREAM_FILECLIP_ENABLED")
}
return h, fs
}
func TestBuildParseDropfilesRoundTrip(t *testing.T) {
names := []string{`C:\Users\测试\报告 最终版.docx`, "plain.txt", "数据 - 副本 (2).xlsx"}
b := BuildDropfiles(names)
if binary.LittleEndian.Uint32(b[0:]) != dropfilesHeaderLen {
t.Fatalf("pFiles = %d, want %d", binary.LittleEndian.Uint32(b[0:]), dropfilesHeaderLen)
}
if binary.LittleEndian.Uint32(b[16:]) != 1 {
t.Fatal("fWide should be 1 (Unicode)")
}
got, err := ParseDropfiles(b)
if err != nil {
t.Fatal(err)
}
if len(got) != len(names) {
t.Fatalf("got %d names, want %d: %q", len(got), len(names), got)
}
for i := range names {
if got[i] != names[i] {
t.Fatalf("name[%d] = %q, want %q", i, got[i], names[i])
}
}
}
func TestParseDropfilesASCII(t *testing.T) {
// fWide=0 的 ASCII 变体(FreeRDP 老服务器可能使用)
list := []byte("a.txt\x00b.bin\x00\x00")
body := make([]byte, dropfilesHeaderLen+len(list))
binary.LittleEndian.PutUint32(body[0:], dropfilesHeaderLen)
binary.LittleEndian.PutUint32(body[16:], 0)
copy(body[dropfilesHeaderLen:], list)
got, err := ParseDropfiles(body)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != "a.txt" || got[1] != "b.bin" {
t.Fatalf("got %q", got)
}
}
func TestParseDropfilesRejectsBad(t *testing.T) {
if _, err := ParseDropfiles(make([]byte, 8)); err == nil {
t.Fatal("short body should fail")
}
bad := make([]byte, dropfilesHeaderLen)
binary.LittleEndian.PutUint32(bad[0:], 1<<20) // pFiles 越界
if _, err := ParseDropfiles(bad); err == nil {
t.Fatal("out-of-range pFiles should fail")
}
}
func serverFormatListWithHDrop(t *testing.T, h *CliprdrHandler) {
t.Helper()
// long-name Format List:CF_HDROP(id 15) + CF_UNICODETEXT
b := &bytes.Buffer{}
binary.Write(b, binary.LittleEndian, uint32(CF_HDROP))
b.Write(encodeUTF16LE("")) // 标准格式空名
b.Write([]byte{0, 0})
binary.Write(b, binary.LittleEndian, uint32(CF_UNICODETEXT))
b.Write(encodeUTF16LE(""))
b.Write([]byte{0, 0})
h.processFormatList(b.Bytes(), 0)
}
func TestServerFormatListTriggersHDropRequest(t *testing.T) {
h, fs := newFileTestHandler(t)
serverFormatListWithHDrop(t, h)
// 第一条:Format List Response OK;第二条:Format Data Request(CF_HDROP)
if len(fs.pdus) < 2 {
t.Fatalf("expected FORMAT_LIST_RESPONSE + FORMAT_DATA_REQUEST, got %d", len(fs.pdus))
}
req := fs.pdus[1]
if req.msgType != CB_FORMAT_DATA_REQUEST {
t.Fatalf("msgType=%#x, want FORMAT_DATA_REQUEST", req.msgType)
}
if id := binary.LittleEndian.Uint32(req.body); id != CF_HDROP {
t.Fatalf("requested format=%d, want %d", id, CF_HDROP)
}
if h.lastRequestedFormat != CF_HDROP {
t.Fatalf("lastRequestedFormat=%d", h.lastRequestedFormat)
}
}
func TestRemoteFilesReceivedViaFormatDataResponse(t *testing.T) {
h, _ := newFileTestHandler(t)
var gotNames []string
h.SetFileCallbacks(func(names []string) { gotNames = names }, nil, nil)
serverFormatListWithHDrop(t, h)
payload := BuildDropfiles([]string{`D:\share\a.pdf`, `D:\share\b.pdf`})
h.processFormatDataResponse(payload, CB_RESPONSE_OK)
if len(gotNames) != 2 || gotNames[0] != `D:\share\a.pdf` {
t.Fatalf("onRemoteFiles got %q", gotNames)
}
if names := h.RemoteFileNames(); len(names) != 2 {
t.Fatalf("RemoteFileNames = %q", names)
}
}
func TestFormatDataRequestServesStagedFiles(t *testing.T) {
h, fs := newFileTestHandler(t)
h.SetLocalFiles([]LocalFile{{Name: "x.bin", Data: []byte{1, 2, 3}}})
req := make([]byte, 4)
binary.LittleEndian.PutUint32(req, CF_FILE_GROUP_DESCRIPTORW)
h.processFormatDataRequest(req)
resp := fs.last()
if resp.msgType != CB_FORMAT_DATA_RESPONSE || resp.flags != CB_RESPONSE_OK {
t.Fatalf("msgType=%#x flags=%#x", resp.msgType, resp.flags)
}
names, sizes, err := ParseFileGroupDescriptorW(resp.body)
if err != nil || len(names) != 1 || names[0] != "x.bin" || sizes[0] != 3 {
t.Fatalf("FileGroupDescriptorW parse=%q %v err=%v", names, sizes, err)
}
}
func TestFormatDataRequestNoFilesFails(t *testing.T) {
h, fs := newFileTestHandler(t)
req := make([]byte, 4)
binary.LittleEndian.PutUint32(req, CF_FILE_GROUP_DESCRIPTORW)
h.processFormatDataRequest(req)
if fs.last().flags != CB_RESPONSE_FAIL {
t.Fatal("expected FAIL when no staged files")
}
}
// TestBuildParseFileGroupDescriptorWRoundTrip:编码→解码保持名称与大小。
func TestBuildParseFileGroupDescriptorWRoundTrip(t *testing.T) {
files := []LocalFile{
{Name: "报 告 最终版.docx", Data: make([]byte, 5)},
{Name: "小.txt", Data: []byte("hi")},
}
payload := BuildFileGroupDescriptorW(files)
if len(payload) != 4+2*fileDescriptorSize {
t.Fatalf("payload=%d bytes, want %d", len(payload), 4+2*fileDescriptorSize)
}
names, sizes, err := ParseFileGroupDescriptorW(payload)
if err != nil {
t.Fatal(err)
}
if len(names) != 2 {
t.Fatalf("names=%q", names)
}
if names[0] != "报 告 最终版.docx" || names[1] != "小.txt" {
t.Fatalf("names=%q", names)
}
if sizes[0] != 5 || sizes[1] != 2 {
t.Fatalf("sizes=%v", sizes)
}
}
// TestServerFileDescriptorListFlow:服务器通告 "FileGroupDescriptorW" 时
// 应以其 ID 请求格式数据,并用描述符填充远端文件列表(含大小)。
func TestServerFileDescriptorListFlow(t *testing.T) {
h, fs := newFileTestHandler(t)
var gotNames []string
h.SetFileCallbacks(func(names []string) { gotNames = names }, nil, nil)
// 服务器 Format List:FileGroupDescriptorW(id 0xC181) + FileContents + DropEffect
b := &bytes.Buffer{}
writeNamed := func(id uint32, name string) {
binary.Write(b, binary.LittleEndian, id)
b.Write(encodeUTF16LE(name))
b.Write([]byte{0, 0})
}
writeNamed(0xC181, FormatNameFileGroupDescriptorW)
writeNamed(0xC182, FormatNameFileContents)
writeNamed(0xC17E, FormatNameDropEffect)
h.processFormatList(b.Bytes(), 0)
req := fs.last()
if req.msgType != CB_FORMAT_DATA_REQUEST ||
binary.LittleEndian.Uint32(req.body) != 0xC181 {
t.Fatalf("expected FormatDataRequest(0xC181), got %#x id=%d",
req.msgType, binary.LittleEndian.Uint32(req.body))
}
payload := BuildFileGroupDescriptorW([]LocalFile{{Name: `C:\doc\a.pdf`, Data: make([]byte, 9)}})
h.processFormatDataResponse(payload, CB_RESPONSE_OK)
if len(gotNames) != 1 || gotNames[0] != `C:\doc\a.pdf` {
t.Fatalf("onRemoteFiles got %q", gotNames)
}
// 已知大小:RequestRemoteFile 应直接发 RANGE(0) 而非 SIZE
if err := h.RequestRemoteFile(0); err != nil {
t.Fatal(err)
}
fr := fs.last()
if binary.LittleEndian.Uint32(fr.body[8:]) != FILECONTENTS_RANGE ||
binary.LittleEndian.Uint32(fr.body[12:]) != 0 {
t.Fatalf("want direct RANGE(0), flags=%d pos=%d",
binary.LittleEndian.Uint32(fr.body[8:]), binary.LittleEndian.Uint32(fr.body[12:]))
}
}
// TestDropEffectRequest:资源管理器右键菜单查询 "Preferred DropEffect",
// 有暂存文件时必须回 OK + DROPEFFECT_COPY,否则"粘贴"被置灰。
func TestDropEffectRequest(t *testing.T) {
h, fs := newFileTestHandler(t)
req := make([]byte, 4)
binary.LittleEndian.PutUint32(req, CF_DROP_EFFECT)
h.processFormatDataRequest(req)
if fs.last().flags != CB_RESPONSE_FAIL {
t.Fatal("no staged files: expected FAIL for DropEffect")
}
h.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
h.processFormatDataRequest(req)
resp := fs.last()
if resp.msgType != CB_FORMAT_DATA_RESPONSE || resp.flags != CB_RESPONSE_OK {
t.Fatalf("msgType=%#x flags=%#x", resp.msgType, resp.flags)
}
if v := binary.LittleEndian.Uint32(resp.body); v != DROPEFFECT_COPY {
t.Fatalf("DropEffect=%d, want %d", v, DROPEFFECT_COPY)
}
}
func TestFormatListAdvertisesDropEffectWithFiles(t *testing.T) {
h, fs := newFileTestHandler(t)
h.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
body := fs.last().body
if !bytes.Contains(body, encodeUTF16LE(FormatNameDropEffect)) {
t.Fatal("staged Format List should advertise Preferred DropEffect")
}
}
func TestFileContentsRequestServesSizeAndRange(t *testing.T) {
h, fs := newFileTestHandler(t)
data := bytes.Repeat([]byte{0xA5}, 300*1024) // 跨两个 RANGE 段
h.SetLocalFiles([]LocalFile{{Name: "big.bin", Data: data}})
// SIZE 请求
szReq := make([]byte, 24)
binary.LittleEndian.PutUint32(szReq[0:], 7) // streamId
binary.LittleEndian.PutUint32(szReq[4:], 0) // lindex
binary.LittleEndian.PutUint32(szReq[8:], FILECONTENTS_SIZE)
binary.LittleEndian.PutUint32(szReq[20:], 8)
h.processFileContentsRequest(szReq)
resp := fs.last()
if resp.msgType != CB_FILECONTENTS_RESPONSE || resp.flags != CB_RESPONSE_OK {
t.Fatalf("SIZE: msgType=%#x flags=%#x", resp.msgType, resp.flags)
}
if sid := binary.LittleEndian.Uint32(resp.body); sid != 7 {
t.Fatalf("SIZE: streamId=%d", sid)
}
if size := binary.LittleEndian.Uint64(resp.body[4:]); size != uint64(len(data)) {
t.Fatalf("SIZE: got %d want %d", size, len(data))
}
// RANGE 请求(尾段裁剪到文件末尾)
rng := make([]byte, 24)
binary.LittleEndian.PutUint32(rng[0:], 7)
binary.LittleEndian.PutUint32(rng[8:], FILECONTENTS_RANGE)
binary.LittleEndian.PutUint32(rng[12:], 290*1024) // pos
binary.LittleEndian.PutUint32(rng[20:], 1<<20) // 请求远超末尾
h.processFileContentsRequest(rng)
resp = fs.last()
got := resp.body[4:]
if len(got) != 10*1024 {
t.Fatalf("RANGE: got %d bytes, want clamped %d", len(got), 10*1024)
}
if got[0] != 0xA5 {
t.Fatal("RANGE: wrong payload")
}
// 越界 lindex → FAIL 且带 streamId
bad := make([]byte, 24)
binary.LittleEndian.PutUint32(bad[0:], 9)
binary.LittleEndian.PutUint32(bad[4:], 5)
binary.LittleEndian.PutUint32(bad[8:], FILECONTENTS_SIZE)
h.processFileContentsRequest(bad)
resp = fs.last()
if resp.flags != CB_RESPONSE_FAIL || len(resp.body) != 4 ||
binary.LittleEndian.Uint32(resp.body) != 9 {
t.Fatalf("unknown lindex: flags=%#x body=% X", resp.flags, resp.body)
}
}
// TestRemoteFileTransferChain 走完 SIZE→RANGE→完成的完整拉取链,
// 并验证服务器裁剪返回段时按实际长度推进。
func TestRemoteFileTransferChain(t *testing.T) {
h, fs := newFileTestHandler(t)
var resultName string
var resultData []byte
var progGot, progTotal int64
h.SetFileCallbacks(func(names []string) {},
func(index int, name string, data []byte) { resultName, resultData = name, data },
func(index int, received, total int64) { progGot, progTotal = received, total })
serverFormatListWithHDrop(t, h)
payload := BuildDropfiles([]string{`E:\doc\说明 书.pdf`})
h.processFormatDataResponse(payload, CB_RESPONSE_OK)
file := bytes.Repeat([]byte{0x5A}, fileRangeChunk+1000)
if err := h.RequestRemoteFile(0); err != nil {
t.Fatal(err)
}
// 1) SIZE 请求
req := fs.last()
if req.msgType != CB_FILECONTENTS_REQUEST ||
binary.LittleEndian.Uint32(req.body[8:]) != FILECONTENTS_SIZE {
t.Fatalf("step1: msgType=%#x flags=%#x", req.msgType, binary.LittleEndian.Uint32(req.body[8:]))
}
sid := binary.LittleEndian.Uint32(req.body[0:])
// SIZE 响应 → 应发出 RANGE(0, fileRangeChunk)
h.processFileContentsResponse(append(u32le(sid), u64le(uint64(len(file)))...), CB_RESPONSE_OK)
req = fs.last()
if binary.LittleEndian.Uint32(req.body[8:]) != FILECONTENTS_RANGE ||
binary.LittleEndian.Uint32(req.body[12:]) != 0 ||
binary.LittleEndian.Uint32(req.body[20:]) != fileRangeChunk {
t.Fatalf("step2: want RANGE(0,%d), got pos=%d cb=%d", fileRangeChunk,
binary.LittleEndian.Uint32(req.body[12:]), binary.LittleEndian.Uint32(req.body[20:]))
}
// RANGE 响应(服务器只回一半)→ 应从实际到达位置续传
h.processFileContentsResponse(append(u32le(sid), file[:fileRangeChunk/2]...), CB_RESPONSE_OK)
req = fs.last()
if p := binary.LittleEndian.Uint32(req.body[12:]); p != fileRangeChunk/2 {
t.Fatalf("step3: want resume pos=%d, got %d", fileRangeChunk/2, p)
}
if progGot != fileRangeChunk/2 || progTotal != int64(len(file)) {
t.Fatalf("progress got=%d total=%d", progGot, progTotal)
}
// 剩余数据 → 完成回调
h.processFileContentsResponse(append(u32le(sid), file[fileRangeChunk/2:]...), CB_RESPONSE_OK)
if resultName != `E:\doc\说明 书.pdf` {
t.Fatalf("result name=%q", resultName)
}
if !bytes.Equal(resultData, file) {
t.Fatalf("result %d bytes, want %d", len(resultData), len(file))
}
}
func TestRemoteFileTransferFailure(t *testing.T) {
h, fs := newFileTestHandler(t)
var failData []byte
var failName string
h.SetFileCallbacks(func(names []string) {},
func(index int, name string, data []byte) { failName, failData = name, data },
nil)
serverFormatListWithHDrop(t, h)
h.processFormatDataResponse(BuildDropfiles([]string{"gone.txt"}), CB_RESPONSE_OK)
if err := h.RequestRemoteFile(0); err != nil {
t.Fatal(err)
}
sid := binary.LittleEndian.Uint32(fs.last().body[0:])
h.processFileContentsResponse(u32le(sid), CB_RESPONSE_FAIL)
if failName != "gone.txt" || failData != nil {
t.Fatalf("failure delivery: name=%q data=%v", failName, failData)
}
}
// TestServerLocalDropEffectId:Windows 用它本地的 "Preferred DropEffect"
// 注册格式 ID(实测 0xC17E)查询而非我方广告 ID,需按 DropEffect 应答。
func TestServerLocalDropEffectId(t *testing.T) {
h, fs := newFileTestHandler(t)
h.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
req := make([]byte, 4)
binary.LittleEndian.PutUint32(req, 0xC17E)
h.processFormatDataRequest(req)
resp := fs.last()
if resp.flags != CB_RESPONSE_OK || len(resp.body) != 4 ||
binary.LittleEndian.Uint32(resp.body) != DROPEFFECT_COPY {
t.Fatalf("heuristic DropEffect reply failed: flags=%#x body=% X", resp.flags, resp.body)
}
// 服务器通告过名称后,精确匹配其本地 ID
h2, fs2 := newFileTestHandler(t)
b := &bytes.Buffer{}
binary.Write(b, binary.LittleEndian, uint32(0xC17E))
b.Write(encodeUTF16LE(FormatNameDropEffect))
b.Write([]byte{0, 0})
h2.processFormatList(b.Bytes(), 0)
h2.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
h2.processFormatDataRequest(req)
if fs2.last().flags != CB_RESPONSE_OK {
t.Fatal("learned serverDropEffectId should be answered with OK")
}
// 无文件时 FAIL
h3, fs3 := newFileTestHandler(t)
b3 := &bytes.Buffer{}
binary.Write(b3, binary.LittleEndian, uint32(0xC17E))
b3.Write(encodeUTF16LE(FormatNameDropEffect))
b3.Write([]byte{0, 0})
h3.processFormatList(b3.Bytes(), 0)
h3.processFormatDataRequest(req)
if fs3.last().flags != CB_RESPONSE_FAIL {
t.Fatal("no staged files: server-local DropEffect should FAIL")
}
}
func TestRequestRemoteFileOutOfRange(t *testing.T) {
h, _ := newFileTestHandler(t)
if err := h.RequestRemoteFile(3); err == nil {
t.Fatal("expected error for out-of-range index")
}
}
func TestFormatListAdvertisesFileFormatsWhenStaged(t *testing.T) {
h, fs := newFileTestHandler(t) // long-name 模式
h.sendFormatList()
body := fs.last().body
if bytes.Contains(body, encodeUTF16LE(FormatNameFileGroupDescriptorW)) {
t.Fatal("file formats must not be advertised before staging files")
}
h.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
body = fs.last().body
if !bytes.Contains(body, encodeUTF16LE(FormatNameFileGroupDescriptorW)) ||
!bytes.Contains(body, encodeUTF16LE(FormatNameFileContents)) ||
!bytes.Contains(body, encodeUTF16LE(FormatNameDropEffect)) {
t.Fatal("staged Format List should advertise FileGroupDescriptorW/FileContents/DropEffect")
}
// short-name 模式(名称为原始 ASCII 字节)
h2, fs2 := newFileTestHandler(t)
h2.useLongFormatNames = false
h2.SetLocalFiles([]LocalFile{{Name: "a.txt", Data: []byte("hi")}})
body = fs2.last().body
if !bytes.Contains(body, []byte("FileGroupDescriptorW")) {
t.Fatalf("short-name Format List should carry FileGroupDescriptorW: % X", body[:min(80, len(body))])
}
}
func u64le(v uint64) []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, v)
return b
}
// TestParseFileGroupDescriptorWLargeDirectory:远端复制含数千文件的目录
// 是正常操作(曾因固定 1024 项上限整单被拒);超过防御性上限才拒绝。
func TestParseFileGroupDescriptorWLargeDirectory(t *testing.T) {
build := func(n uint32) []byte {
body := make([]byte, 4+int(n)*fileDescriptorSize)
binary.LittleEndian.PutUint32(body[0:], n)
for i := uint32(0); i < n; i++ {
d := body[4+int(i)*fileDescriptorSize:]
binary.LittleEndian.PutUint32(d[0:], FD_ATTRIBUTES|FD_FILESIZE|FD_PROGRESSUI)
binary.LittleEndian.PutUint32(d[68:], 7) // 大小低 32 位
copy(d[72:], asciiUTF16(fmt.Sprintf("f%d.txt", i)))
}
return body
}
// 5000 项:必须接受
names, _, err := ParseFileGroupDescriptorW(build(5000))
if err != nil || len(names) != 5000 {
t.Fatalf("5000 items: err=%v names=%d", err, len(names))
}
if names[4999] != "f4999.txt" {
t.Fatalf("last name=%q", names[4999])
}
// 超过 maxDescriptorItems:拒绝
if _, _, err := ParseFileGroupDescriptorW(build(maxDescriptorItems + 1)); err == nil {
t.Fatal("over-limit cItems should be rejected")
}
// cItems 声明与载荷不符:拒绝
short := build(3)[:4+2*fileDescriptorSize]
binary.LittleEndian.PutUint32(short[0:], 3)
if _, _, err := ParseFileGroupDescriptorW(short); err == nil {
t.Fatal("truncated payload should be rejected")
}
}
// asciiUTF16 把纯 ASCII 名编码为 UTF-16LE(NUL 结尾),供构造描述符用。
func asciiUTF16(s string) []byte {
b := make([]byte, 0, (len(s)+1)*2)
for _, c := range []byte(s) {
b = append(b, c, 0)
}
return append(b, 0, 0)
}
+678
View File
@@ -0,0 +1,678 @@
// Package cliprdr handler.go implements a cross-platform CLIPRDR
// (Clipboard Virtual Channel Extension, MS-RDPECLIP) handler for
// bidirectional text clipboard sharing between RDP client and server.
//
// Only text formats (CF_UNICODETEXT / CF_TEXT) are supported.
package cliprdr
import (
"bytes"
"encoding/binary"
"log/slog"
"strings"
"sync"
"unicode/utf16"
"git.zeroonesoft.cn/golib/rdplib/core"
)
// CliprdrHandler implements plugin.ChannelTransport for the "cliprdr"
// static virtual channel. It uses callbacks for clipboard integration
// so that any UI toolkit can wire in its own clipboard access.
type CliprdrHandler struct {
channelSender core.ChannelSender
useLongFormatNames bool
// serverCapsReceived is set when the server's CB_CLIP_CAPS PDU has been
// processed. Per MS-RDPECLIP §1.3.2.1 the server sends CB_CLIP_CAPS
// before CB_MONITOR_READY, so FORMAT_LIST should only be sent once both
// have arrived.
serverCapsReceived bool
// monitorReady is set when CB_MONITOR_READY has been received.
monitorReady bool
// onRemoteClipboardChanged is called with the text when the server's
// clipboard content arrives.
onRemoteClipboardChanged func(text string)
// getLocalClipboardText is called to retrieve the current local
// clipboard text when the server requests it.
getLocalClipboardText func() string
// onRemoteClipboardImage is called with PNG-encoded bytes when the
// server's clipboard image (PNG / CF_DIB) arrives.
onRemoteClipboardImage func(png []byte)
// getLocalClipboardImage is called to retrieve the current local
// clipboard image (PNG-encoded) when the server requests the "PNG"
// registered format. Returns nil when no image is available.
getLocalClipboardImage func() []byte
// onRemoteClipboardHTML is called with the HTML content when the server's
// clipboard "HTML Format" data arrives (fragment already extracted from
// the CF_HTML envelope).
onRemoteClipboardHTML func(html string)
// getLocalClipboardHTML is called to retrieve the current local clipboard
// HTML (raw fragment/document, without the CF_HTML envelope) when the
// server requests the "HTML Format" registered format.
// Returns "" when no HTML is available.
getLocalClipboardHTML func() string
// remoteHTMLFormatId 记录服务器 Format List 中 "HTML Format" 的格式 ID,
// 用于识别 Format Data Response(服务端注册格式的 ID 由服务端分配)。
remoteHTMLFormatId uint32
// serverDropEffectId 记录服务器 Format List 中 "Preferred DropEffect"
// 的服务器本地 ID(注册格式 ID 各端自定,按名称对齐)。
serverDropEffectId uint32
// lastRequestedFormat 记录我方最后一次 Format Data Request 的格式,
// 以正确解读 Format Data Response(PNG 原样、DIB 转码、文本按 UTF-16)。
lastRequestedFormat uint32
// suppressNextLocalChange prevents an echo loop:
// server→client clipboard update triggers a local clipboard change
// event which would otherwise be sent back to the server.
suppressNextLocalChange bool
// --- File clipboard (CF_HDROP + FileContentsRequest/Response) -----------
//
// Browser clipboards cannot hold files, so files are "staged" explicitly
// by the UI (file picker / drag-drop): staged files are advertised as
// CF_HDROP in Format List; the server fetches their bytes through
// FileContentsRequest. Files copied on the server arrive as a CF_HDROP
// path list, and the UI pulls bytes per file via RequestRemoteFile.
mu sync.Mutex
// serverFileClip is set when the server advertised CB_STREAM_FILECLIP_ENABLED.
// CF_HDROP is only advertised/requested when both sides support file streaming.
serverFileClip bool
// localFiles are files staged by the local UI, advertised as CF_HDROP.
localFiles []LocalFile
// remoteFiles are the paths from the server's last file list payload.
remoteFiles []string
// remoteFileSizes are the sizes from the server's FileGroupDescriptorW
// (-1 when unknown; only valid alongside remoteFiles from descriptors).
remoteFileSizes []int64
// streams tracks in-flight outgoing FileContentsRequest transfers
// (remote → local download) keyed by our StreamId.
streams map[uint32]*fileStream
// nextStreamID is the StreamId counter for outgoing FileContentsRequests.
nextStreamID uint32
onRemoteFiles func(names []string)
onFileData func(index int, name string, data []byte)
onFileProgress func(index int, received, total int64)
}
// NewHandler creates a CliprdrHandler.
//
// - onRemote is called when the server clipboard text is received.
// - getLocal is called to retrieve the current local clipboard text.
//
// Either callback may be nil. Image callbacks can be attached later via
// SetImageCallbacks.
func NewHandler(onRemote func(text string), getLocal func() string) *CliprdrHandler {
return &CliprdrHandler{
onRemoteClipboardChanged: onRemote,
getLocalClipboardText: getLocal,
streams: map[uint32]*fileStream{},
}
}
// SetImageCallbacks attaches image clipboard callbacks (both optional).
//
// - onRemoteImage receives PNG-encoded bytes of the server clipboard image.
// - getLocalImage returns PNG-encoded bytes of the local clipboard image
// (nil when unavailable), used to answer server requests for "PNG".
func (h *CliprdrHandler) SetImageCallbacks(onRemoteImage func(png []byte), getLocalImage func() []byte) {
h.onRemoteClipboardImage = onRemoteImage
h.getLocalClipboardImage = getLocalImage
}
// SetHTMLCallbacks attaches HTML clipboard callbacks (both optional).
//
// - onRemoteHTML receives the HTML content when the server's clipboard
// "HTML Format" data arrives (fragment extracted from the CF_HTML envelope).
// - getLocalHTML returns the local clipboard HTML (raw HTML, no CF_HTML
// envelope; empty when unavailable), used to answer server requests for
// the "HTML Format" registered format.
func (h *CliprdrHandler) SetHTMLCallbacks(onRemoteHTML func(html string), getLocalHTML func() string) {
h.onRemoteClipboardHTML = onRemoteHTML
h.getLocalClipboardHTML = getLocalHTML
}
// --- plugin.ChannelTransport interface ------------------------------------
func (h *CliprdrHandler) GetType() (string, uint32) {
return ChannelName, ChannelOption
}
func (h *CliprdrHandler) Sender(f core.ChannelSender) {
h.channelSender = f
}
// Process handles a reassembled CLIPRDR PDU from the server.
func (h *CliprdrHandler) Process(s []byte) {
if len(s) < 8 {
return
}
r := bytes.NewReader(s)
msgType, _ := core.ReadUint16LE(r)
msgFlags, _ := core.ReadUint16LE(r)
dataLen, _ := core.ReadUInt32LE(r)
body := make([]byte, dataLen)
if dataLen > 0 {
n, _ := r.Read(body)
body = body[:n]
}
slog.Debug("cliprdr recv", "msgType", msgType, "msgFlags", msgFlags, "dataLen", dataLen)
switch msgType {
case CB_CLIP_CAPS:
h.processClipCaps(body)
case CB_MONITOR_READY:
h.processMonitorReady()
case CB_FORMAT_LIST:
h.processFormatList(body, msgFlags)
case CB_FORMAT_LIST_RESPONSE:
h.processFormatListResponse(msgFlags)
case CB_FORMAT_DATA_REQUEST:
h.processFormatDataRequest(body)
case CB_FORMAT_DATA_RESPONSE:
h.processFormatDataResponse(body, msgFlags)
case CB_FILECONTENTS_REQUEST:
h.processFileContentsRequest(body)
case CB_FILECONTENTS_RESPONSE:
h.processFileContentsResponse(body, msgFlags)
case CB_LOCK_CLIPDATA, CB_UNLOCK_CLIPDATA:
// ignored
default:
slog.Debug("cliprdr: unhandled msgType", "msgType", msgType)
}
}
// --- Clipboard Capabilities (MS-RDPECLIP 2.2.2.1) -------------------------
func (h *CliprdrHandler) processClipCaps(body []byte) {
if len(body) < 4 {
return
}
cCapSets := binary.LittleEndian.Uint16(body[0:2])
// pad1 at [2:4]
offset := 4
for i := 0; i < int(cCapSets); i++ {
if offset+4 > len(body) {
break
}
capType := binary.LittleEndian.Uint16(body[offset:])
capLen := binary.LittleEndian.Uint16(body[offset+2:])
if capType == CB_CAPSTYPE_GENERAL && capLen >= 12 {
generalFlags := binary.LittleEndian.Uint32(body[offset+8:])
h.useLongFormatNames = generalFlags&CB_USE_LONG_FORMAT_NAMES != 0
h.mu.Lock()
h.serverFileClip = generalFlags&CB_STREAM_FILECLIP_ENABLED != 0
fileClip := h.serverFileClip
h.mu.Unlock()
slog.Debug("cliprdr: server caps", "generalFlags", generalFlags,
"longNames", h.useLongFormatNames, "fileClip", fileClip)
}
offset += int(capLen)
}
h.serverCapsReceived = true
// If CB_MONITOR_READY already arrived before CB_CLIP_CAPS (non-standard
// ordering), send the FORMAT_LIST now that we have correct capabilities.
if h.monitorReady {
h.sendFormatList()
}
}
func (h *CliprdrHandler) sendClipCaps() {
b := &bytes.Buffer{}
// General capability set: type(2) + length(2) + version(4) + flags(4)
binary.Write(b, binary.LittleEndian, uint16(CB_CAPSTYPE_GENERAL))
binary.Write(b, binary.LittleEndian, uint16(12))
binary.Write(b, binary.LittleEndian, uint32(CB_CAPS_VERSION_2))
// CB_STREAM_FILECLIP_ENABLED + CB_FILECLIP_NO_FILE_PATHS:声明支持
// FileGroupDescriptorW/FileContents 流式文件传输(mstsc 同款),
// 服务器未开时其会忽略这些位。
binary.Write(b, binary.LittleEndian,
uint32(CB_USE_LONG_FORMAT_NAMES|CB_STREAM_FILECLIP_ENABLED|CB_FILECLIP_NO_FILE_PATHS))
// cCapabilitySets(2) + pad1(2) + capabilitySet
body := &bytes.Buffer{}
binary.Write(body, binary.LittleEndian, uint16(1))
binary.Write(body, binary.LittleEndian, uint16(0))
body.Write(b.Bytes())
h.sendPDU(CB_CLIP_CAPS, 0, body.Bytes())
}
// --- Monitor Ready (MS-RDPECLIP 2.2.2.2) ----------------------------------
func (h *CliprdrHandler) processMonitorReady() {
slog.Debug("cliprdr: server Monitor Ready")
h.monitorReady = true
h.sendClipCaps()
// Per MS-RDPECLIP §1.3.2.1 the server sends CB_CLIP_CAPS before
// CB_MONITOR_READY. Only send FORMAT_LIST after server caps are known
// so useLongFormatNames is set correctly. If CB_CLIP_CAPS hasn't been
// received yet (non-standard ordering), defer until processClipCaps fires.
if h.serverCapsReceived {
h.sendFormatList()
}
}
// --- Format List (MS-RDPECLIP 2.2.3.1) ------------------------------------
func (h *CliprdrHandler) sendFormatList() {
h.mu.Lock()
staged := len(h.localFiles) > 0
fileClip := h.serverFileClip
h.mu.Unlock()
// 仅当双方都声明 CB_STREAM_FILECLIP_ENABLED 且本地已暂存文件时
// 才广告 CF_HDROP,避免服务器把普通粘贴当文件处理。
advertiseFiles := staged && fileClip
b := &bytes.Buffer{}
if h.useLongFormatNames {
// Long Format Name: formatId(4) + wszFormatName(null-terminated UTF-16LE)
writeFormat := func(id uint32, name string) {
binary.Write(b, binary.LittleEndian, id)
b.Write(encodeUTF16LE(name))
b.Write([]byte{0, 0}) // null terminator
}
writeFormat(CF_UNICODETEXT, "") // 空名 = 标准格式
writeFormat(CF_PNG, FormatNamePNG) // 注册格式 "PNG"
writeFormat(CF_HTML_FORMAT_ID, FormatNameHTML) // 注册格式 "HTML Format"
if advertiseFiles {
// 文件传输三件套(mstsc 同款,名称必须一致):
// 描述符列表 + 流式内容 + 复制语义
writeFormat(CF_FILE_GROUP_DESCRIPTORW, FormatNameFileGroupDescriptorW)
writeFormat(CF_FILE_CONTENTS, FormatNameFileContents)
writeFormat(CF_DROP_EFFECT, FormatNameDropEffect)
}
} else {
// Short Format Name: formatId(4) + formatName[32]
writeShort := func(id uint32, name string) {
binary.Write(b, binary.LittleEndian, id)
nameBuf := make([]byte, 32)
copy(nameBuf, name)
b.Write(nameBuf)
}
writeShort(CF_UNICODETEXT, "")
writeShort(CF_PNG, FormatNamePNG)
writeShort(CF_HTML_FORMAT_ID, FormatNameHTML)
if advertiseFiles {
writeShort(CF_FILE_GROUP_DESCRIPTORW, FormatNameFileGroupDescriptorW)
writeShort(CF_FILE_CONTENTS, FormatNameFileContents)
writeShort(CF_DROP_EFFECT, FormatNameDropEffect)
}
}
h.sendPDU(CB_FORMAT_LIST, 0, b.Bytes())
}
func (h *CliprdrHandler) processFormatList(body []byte, msgFlags uint16) {
formats := h.parseFormatList(body, msgFlags)
slog.Debug("cliprdr: server Format List", "formats", formats)
// 记录服务器侧 "Preferred DropEffect" 的本地 ID(注册格式 ID 各端自定)
for _, f := range formats {
if strings.EqualFold(f.FormatName, FormatNameDropEffect) {
h.mu.Lock()
h.serverDropEffectId = f.FormatId
h.mu.Unlock()
}
}
// Always respond OK
h.sendPDU(CB_FORMAT_LIST_RESPONSE, CB_RESPONSE_OK, nil)
// 请求优先级:文件描述符(远端复制了文件)> CF_HDROP > PNG > CF_DIB >
// HTML > 文本。资源管理器复制文件时通常还附带文件名文本,文件列表
// 必须最先识别。
for _, f := range formats {
if strings.EqualFold(f.FormatName, FormatNameFileGroupDescriptorW) {
h.mu.Lock()
fileClip := h.serverFileClip
h.mu.Unlock()
if fileClip {
slog.Debug("cliprdr: server offers files (FileGroupDescriptorW)")
h.lastRequestedFormat = CF_FILE_GROUP_DESCRIPTORW
h.sendFormatDataRequest(f.FormatId)
return
}
}
}
for _, f := range formats {
if f.FormatId == uint32(CF_HDROP) || strings.EqualFold(f.FormatName, FormatNameHDrop) {
h.mu.Lock()
fileClip := h.serverFileClip
h.mu.Unlock()
if fileClip {
slog.Debug("cliprdr: server offers files (CF_HDROP)")
h.lastRequestedFormat = CF_HDROP
h.sendFormatDataRequest(CF_HDROP)
return
}
}
}
for _, f := range formats {
if f.FormatId == CF_PNG || strings.EqualFold(f.FormatName, FormatNamePNG) ||
strings.EqualFold(f.FormatName, "image/png") {
h.lastRequestedFormat = f.FormatId
h.sendFormatDataRequest(f.FormatId)
return
}
}
for _, f := range formats {
if f.FormatId == CF_DIB {
h.lastRequestedFormat = CF_DIB
h.sendFormatDataRequest(CF_DIB)
return
}
}
// HTML Format:远端复制的富文本(浏览器/Office),保格式优于纯文本
for _, f := range formats {
if f.FormatId == CF_HTML_FORMAT_ID || strings.EqualFold(f.FormatName, FormatNameHTML) {
h.remoteHTMLFormatId = f.FormatId
h.lastRequestedFormat = f.FormatId
h.sendFormatDataRequest(f.FormatId)
return
}
}
for _, f := range formats {
if f.FormatId == CF_UNICODETEXT {
h.lastRequestedFormat = CF_UNICODETEXT
h.sendFormatDataRequest(CF_UNICODETEXT)
return
}
}
for _, f := range formats {
if f.FormatId == CF_TEXT {
h.lastRequestedFormat = CF_TEXT
h.sendFormatDataRequest(CF_TEXT)
return
}
}
}
func (h *CliprdrHandler) parseFormatList(body []byte, msgFlags uint16) []CliprdrFormat {
var formats []CliprdrFormat
if h.useLongFormatNames && (msgFlags&CB_ASCII_NAMES == 0) {
// Long Format Names (MS-RDPECLIP 2.2.3.1.1.1)
offset := 0
for offset+4 <= len(body) {
fmtId := binary.LittleEndian.Uint32(body[offset:])
offset += 4
// Read null-terminated UTF-16LE string
nameEnd := offset
for nameEnd+1 < len(body) {
if body[nameEnd] == 0 && body[nameEnd+1] == 0 {
break
}
nameEnd += 2
}
name := decodeUTF16LE(body[offset:nameEnd])
offset = nameEnd + 2
formats = append(formats, CliprdrFormat{fmtId, name})
}
} else {
// Short Format Names (MS-RDPECLIP 2.2.3.1.1.2)
offset := 0
for offset+36 <= len(body) {
fmtId := binary.LittleEndian.Uint32(body[offset:])
nameBytes := body[offset+4 : offset+36]
var name string
if msgFlags&CB_ASCII_NAMES != 0 {
name = strings.TrimRight(string(nameBytes), "\x00")
} else {
name = decodeUTF16LE(nameBytes)
name = strings.TrimRight(name, "\x00")
}
formats = append(formats, CliprdrFormat{fmtId, name})
offset += 36
}
}
return formats
}
func (h *CliprdrHandler) processFormatListResponse(msgFlags uint16) {
if msgFlags&CB_RESPONSE_OK != 0 {
slog.Debug("cliprdr: Format List Response OK")
} else {
slog.Warn("cliprdr: Format List Response FAIL")
}
}
// --- Format Data Request / Response (MS-RDPECLIP 2.2.5) --------------------
func (h *CliprdrHandler) sendFormatDataRequest(formatId uint32) {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, formatId)
h.sendPDU(CB_FORMAT_DATA_REQUEST, 0, b)
slog.Debug("cliprdr: sent Format Data Request", "formatId", formatId)
}
func (h *CliprdrHandler) processFormatDataRequest(body []byte) {
if len(body) < 4 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
requestedFormat := binary.LittleEndian.Uint32(body[0:4])
slog.Debug("cliprdr: server requests format", "formatId", requestedFormat)
switch requestedFormat {
case CF_PNG:
var png []byte
if h.getLocalClipboardImage != nil {
png = h.getLocalClipboardImage()
}
if len(png) == 0 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, png)
case CF_HTML_FORMAT_ID:
// 本地 HTML(无 CF_HTML 信封)→ 包装成 "HTML Format" 字节流
html := ""
if h.getLocalClipboardHTML != nil {
html = h.getLocalClipboardHTML()
}
if html == "" {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, EncodeCFHTML(html))
case CF_FILE_GROUP_DESCRIPTORW:
// 远端粘贴文件:回 FILE_GROUP_DESCRIPTORW(名称+大小+属性),
// 字节内容由服务器随后以 CB_FILECONTENTS_REQUEST 按 lIndex 拉取
h.mu.Lock()
files := h.localFiles
h.mu.Unlock()
if len(files) == 0 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, BuildFileGroupDescriptorW(files))
case CF_FILE_CONTENTS:
// FileContents 的具体字节一律走 CB_FILECONTENTS_REQUEST(含流式
// 偏移/长度),对整格式 Format Data Request 只能回答不可流式的空体
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
case CF_DROP_EFFECT:
// 有暂存文件时回答 DROPEFFECT_COPY(否则 FAIL)。
// 该查询决定资源管理器右键菜单"粘贴"是否可用。
h.mu.Lock()
hasFiles := len(h.localFiles) > 0
h.mu.Unlock()
if !hasFiles {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, buildDropEffectReply())
case CF_UNICODETEXT:
text := ""
if h.getLocalClipboardText != nil {
text = h.getLocalClipboardText()
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, encodeUTF16LE(text+"\x00"))
case CF_TEXT:
text := ""
if h.getLocalClipboardText != nil {
text = h.getLocalClipboardText()
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, []byte(text+"\x00"))
default:
// 注册格式 ID 各端自定:服务器会用它本地的 "Preferred DropEffect"
// ID 查询(不一定先通告)。该查询决定资源管理器"粘贴"是否可用。
h.mu.Lock()
serverDropEffect := h.serverDropEffectId
hasFiles := len(h.localFiles) > 0
h.mu.Unlock()
// DropEffect 应答:精确命中服务器 ID,或未知注册格式 ID(≥0xC000)
// 且有暂存文件时按 DropEffect 猜测应答
if (serverDropEffect != 0 && requestedFormat == serverDropEffect) ||
(requestedFormat >= 0xC000 && hasFiles) {
if !hasFiles {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
slog.Debug("cliprdr: replying DropEffect", "formatId", requestedFormat)
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, buildDropEffectReply())
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
}
}
func (h *CliprdrHandler) processFormatDataResponse(body []byte, msgFlags uint16) {
if msgFlags&CB_RESPONSE_OK == 0 {
slog.Warn("cliprdr: Format Data Response FAIL")
return
}
// 按我方请求的格式解读响应
switch {
case h.lastRequestedFormat == CF_FILE_GROUP_DESCRIPTORW:
names, sizes, err := ParseFileGroupDescriptorW(body)
if err != nil {
slog.Warn("cliprdr: FileGroupDescriptorW parse failed", "err", err, "len", len(body))
return
}
h.mu.Lock()
h.remoteFiles = names
h.remoteFileSizes = sizes
h.mu.Unlock()
if h.onRemoteFiles != nil {
h.onRemoteFiles(names)
}
case h.lastRequestedFormat == CF_HDROP:
names, err := ParseDropfiles(body)
if err != nil {
slog.Warn("cliprdr: CF_HDROP parse failed", "err", err, "len", len(body))
return
}
h.mu.Lock()
h.remoteFiles = names
h.mu.Unlock()
if h.onRemoteFiles != nil {
h.onRemoteFiles(names)
}
case h.lastRequestedFormat != 0 && h.lastRequestedFormat == h.remoteHTMLFormatId:
// "HTML Format"(服务端注册格式的 ID 与请求时记录的一致)
html := DecodeCFHTML(body)
if html == "" {
return
}
if h.onRemoteClipboardHTML != nil {
h.suppressNextLocalChange = true
h.onRemoteClipboardHTML(html)
}
case h.lastRequestedFormat == CF_PNG:
if len(body) == 0 {
return
}
if h.onRemoteClipboardImage != nil {
h.suppressNextLocalChange = true
png := make([]byte, len(body))
copy(png, body)
h.onRemoteClipboardImage(png)
}
case h.lastRequestedFormat == CF_DIB || h.lastRequestedFormat == CF_DIBV5:
png, err := DIBToPNG(body)
if err != nil {
slog.Warn("cliprdr: DIB→PNG conversion failed", "err", err)
return
}
if h.onRemoteClipboardImage != nil {
h.suppressNextLocalChange = true
h.onRemoteClipboardImage(png)
}
default:
// CF_UNICODETEXT / CF_TEXT → UTF-16LE 文本
text := decodeUTF16LE(body)
text = strings.TrimRight(text, "\x00")
if text != "" && h.onRemoteClipboardChanged != nil {
slog.Debug("cliprdr: received text", "len", len(text))
h.suppressNextLocalChange = true
h.onRemoteClipboardChanged(text)
}
}
}
// --- Public API for local clipboard changes --------------------------------
// OnLocalClipboardChanged notifies the server that the local clipboard
// content has changed. Call this from the UI when the system clipboard
// changes (e.g. via polling or a platform clipboard-change signal).
func (h *CliprdrHandler) OnLocalClipboardChanged() {
if h.suppressNextLocalChange {
h.suppressNextLocalChange = false
return
}
if h.channelSender != nil {
h.sendFormatList()
slog.Debug("cliprdr: local clipboard changed, sent Format List")
}
}
// --- Send helpers ----------------------------------------------------------
func (h *CliprdrHandler) sendPDU(msgType, msgFlags uint16, body []byte) {
if h.channelSender == nil {
return
}
sendClipPDU(h.channelSender, msgType, msgFlags, body)
}
// --- UTF-16LE helpers ------------------------------------------------------
func decodeUTF16LE(b []byte) string {
if len(b) < 2 {
return ""
}
// Trim to even length
if len(b)%2 != 0 {
b = b[:len(b)-1]
}
u16 := make([]uint16, len(b)/2)
for i := range u16 {
u16[i] = binary.LittleEndian.Uint16(b[i*2:])
}
return string(utf16.Decode(u16))
}
func encodeUTF16LE(s string) []byte {
runes := []rune(s)
u16 := utf16.Encode(runes)
b := make([]byte, len(u16)*2)
for i, v := range u16 {
binary.LittleEndian.PutUint16(b[i*2:], v)
}
return b
}
+77
View File
@@ -0,0 +1,77 @@
package cliprdr
import (
"fmt"
"strings"
)
// Windows "HTML Format" 剪贴板编解码(MS-Doc: HTML Clipboard Format)。
//
// 字节流 = 固定文本头 + HTML 文档。头共 5 行、每行 \r\n 结尾:
//
// Version:0.9\r\n (13 字节)
// StartHTML:0000000105\r\n (22 字节)
// EndHTML:0000000256\r\n (20 字节)
// StartFragment:0000000139\r\n (26 字节)
// EndFragment:0000000222\r\n (24 字节)
//
// 偏移为 10 位零填充的十进制**字节**偏移(UTF-8)。固定头总长 105 字节。
const cfHTMLHeaderLen = 13 + 22 + 20 + 26 + 24 // 105
const cfHTMLWrapPrefix = "<html><body><!--StartFragment-->"
const cfHTMLWrapSuffix = "<!--EndFragment--></body></html>"
// EncodeCFHTML 把 HTML 片段(fragment)包装成 "HTML Format" 剪贴板字节流。
func EncodeCFHTML(fragment string) []byte {
startHTML := cfHTMLHeaderLen
startFragment := startHTML + len(cfHTMLWrapPrefix)
endFragment := startFragment + len(fragment)
endHTML := endFragment + len(cfHTMLWrapSuffix)
head := fmt.Sprintf("Version:0.9\r\nStartHTML:%010d\r\nEndHTML:%010d\r\nStartFragment:%010d\r\nEndFragment:%010d\r\n",
startHTML, endHTML, startFragment, endFragment)
doc := cfHTMLWrapPrefix + fragment + cfHTMLWrapSuffix
return []byte(head + doc)
}
// DecodeCFHTML 从 "HTML Format" 剪贴板字节流中提取 StartFragment 到
// EndFragment 之间的 HTML 内容。头部解析失败时回退为整段字符串。
func DecodeCFHTML(data []byte) string {
s := string(data)
lines := strings.Split(s, "\r\n")
var startFragment, endFragment int = -1, -1
// 头最多 5 行;解析到两个 Fragment 偏移即可停止。
for i, ln := range lines {
if i >= 5 {
break
}
if v, ok := parseOffsetLine(ln, "StartFragment:"); ok {
startFragment = v
} else if v, ok := parseOffsetLine(ln, "EndFragment:"); ok {
endFragment = v
}
if startFragment >= 0 && endFragment > startFragment && endFragment <= len(data) {
break
}
}
if startFragment < 0 || endFragment <= startFragment || endFragment > len(data) {
// 回退:无有效偏移时原样返回(去除首行头部不可行,尽力而为)
return s
}
return s[startFragment:endFragment]
}
func parseOffsetLine(line, key string) (int, bool) {
if !strings.HasPrefix(line, key) {
return 0, false
}
v := 0
for _, c := range line[len(key):] {
if c < '0' || c > '9' {
return 0, false
}
v = v*10 + int(c-'0')
}
return v, true
}
+104
View File
@@ -0,0 +1,104 @@
package cliprdr
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestEncodeCFHTMLRoundTrip(t *testing.T) {
frag := "<p style=\"color:red\">红色 <b>加粗</b></p>"
data := EncodeCFHTML(frag)
s := string(data)
if !strings.HasPrefix(s, "Version:0.9\r\n") {
t.Fatalf("missing version header: %q", s[:30])
}
// 固定头长 105 字节,doc 从 105 开始
if !strings.HasPrefix(s[105:], "<html><body><!--StartFragment-->") {
t.Fatalf("html doc should start at offset 105 with wrapper prefix")
}
// 偏移字段自洽(10 位零填充、字节偏移)
expect := func(key string, want int) {
i := strings.Index(s, key)
if i < 0 {
t.Fatalf("missing %s", key)
}
v := 0
for _, c := range s[i+len(key) : i+len(key)+10] {
if c < '0' || c > '9' {
t.Fatalf("%s offset not 10-digit: %q", key, s[i+len(key):i+len(key)+10])
}
v = v*10 + int(c-'0')
}
if v != want {
t.Fatalf("%s=%d, want %d", key, v, want)
}
}
expect("StartHTML:", 105)
expect("EndHTML:", len(data))
expect("StartFragment:", 105+len(cfHTMLWrapPrefix))
expect("EndFragment:", len(data)-len(cfHTMLWrapSuffix))
// 往返
if got := DecodeCFHTML(data); got != frag {
t.Fatalf("round-trip mismatch:\n got %q\nwant %q", got, frag)
}
// 二进制读取不消费编码流(防御:编码流是文本格式)
var n uint32
if err := binary.Read(bytes.NewReader(data[:4]), binary.LittleEndian, &n); err != nil {
t.Fatalf("binary sanity: %v", err)
}
}
func TestDecodeCFHTMLWindowsSample(t *testing.T) {
// 模拟 Windows 剪贴板的实际字节流(头部 + 文档),含 UTF-8 中文
frag := "<p>hello \xe4\xb8\xad\xe6\x96\x87</p>"
doc := "<html><body><!--StartFragment-->" + frag + "<!--EndFragment--></body></html>"
var buf bytes.Buffer
buf.WriteString("Version:0.9\r\nStartHTML:0000000105\r\n")
endHTML := 105 + len(doc)
buf.WriteString("EndHTML:" + pad10(endHTML) + "\r\n")
startFragment := 105 + len(cfHTMLWrapPrefix)
buf.WriteString("StartFragment:" + pad10(startFragment) + "\r\n")
endFragment := startFragment + len(frag)
buf.WriteString("EndFragment:" + pad10(endFragment) + "\r\n")
buf.WriteString(doc)
got := DecodeCFHTML(buf.Bytes())
if got != frag {
t.Fatalf("decode mismatch:\n got %q\nwant %q", got, frag)
}
}
func TestDecodeCFHTMLFallback(t *testing.T) {
// 无有效头的乱数据:回退为原样返回,不得 panic
got := DecodeCFHTML([]byte("not a cf_html stream"))
if got != "not a cf_html stream" {
t.Fatalf("fallback changed content: %q", got)
}
}
func pad10(v int) string {
s := itoa10(v)
if len(s) > 10 {
s = s[len(s)-10:]
}
for len(s) < 10 {
s = "0" + s
}
return s
}
func itoa10(v int) string {
if v == 0 {
return "0"
}
var b []byte
for v > 0 {
b = append([]byte{byte('0' + v%10)}, b...)
v /= 10
}
return string(b)
}
+114
View File
@@ -0,0 +1,114 @@
// image_dib.go 将 Windows 剪贴板的 CF_DIB(BITMAPINFO + 像素)转换为 PNG。
//
// 剪贴板截图类内容常见两种来源:现代应用直接提供 "PNG" 注册格式;老应用
// (如 Server 上的画图)只提供 CF_DIB。本转换器覆盖最常见子集:
// BI_RGB / BI_BITFIELDS 压缩、24/32bpp、底行优先(默认)与 top-down。
package cliprdr
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
)
// DIBToPNG 把 CF_DIB 数据解码并编码为 PNG;不支持的子集返回错误。
func DIBToPNG(dib []byte) ([]byte, error) {
if len(dib) < 40 {
return nil, fmt.Errorf("dib too short: %d", len(dib))
}
biSize := binary.LittleEndian.Uint32(dib[0:4])
w := int(int32(binary.LittleEndian.Uint32(dib[4:8])))
hRaw := int32(binary.LittleEndian.Uint32(dib[8:12]))
topDown := hRaw < 0
h := int(hRaw)
if h < 0 {
h = -h
}
bpp := binary.LittleEndian.Uint16(dib[14:16])
compression := binary.LittleEndian.Uint32(dib[16:20])
if w <= 0 || h <= 0 || w > 16384 || h > 16384 {
return nil, fmt.Errorf("bad dimensions %dx%d", w, h)
}
var rMask, gMask, bMask uint32
pixOff := int(biSize)
switch compression {
case 0: // BI_RGB
rMask, gMask, bMask = 0x00FF0000, 0x0000FF00, 0x000000FF
case 3: // BI_BITFIELDS
if biSize >= 108 { // BITMAPV4HEADER/V5HEADER 内含掩码
rMask = binary.LittleEndian.Uint32(dib[40:44])
gMask = binary.LittleEndian.Uint32(dib[44:48])
bMask = binary.LittleEndian.Uint32(dib[48:52])
} else { // 三个 DWORD 掩码紧跟标准头
if pixOff+12 > len(dib) {
return nil, fmt.Errorf("missing bitfields masks")
}
rMask = binary.LittleEndian.Uint32(dib[pixOff:])
gMask = binary.LittleEndian.Uint32(dib[pixOff+4:])
bMask = binary.LittleEndian.Uint32(dib[pixOff+8:])
pixOff += 12
}
default:
return nil, fmt.Errorf("unsupported compression %d", compression)
}
if bpp != 24 && bpp != 32 {
return nil, fmt.Errorf("unsupported bpp %d", bpp)
}
stride := (w*int(bpp) + 31) / 32 * 4
if pixOff+stride*h > len(dib) {
return nil, fmt.Errorf("dib truncated: need %d, have %d", pixOff+stride*h, len(dib))
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
srcY := y
if !topDown {
srcY = h - 1 - y
}
row := dib[pixOff+srcY*stride:]
for x := 0; x < w; x++ {
var r8, g8, b8 uint8
if bpp == 32 {
v := binary.LittleEndian.Uint32(row[x*4:])
r8, g8, b8 = maskTo8(v, rMask), maskTo8(v, gMask), maskTo8(v, bMask)
} else {
o := x * 3
b8, g8, r8 = row[o], row[o+1], row[o+2]
}
i := img.PixOffset(x, y)
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = r8, g8, b8, 255
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// maskTo8 把 mask 提取出的位域缩放为 8 位
func maskTo8(v, mask uint32) uint8 {
if mask == 0 {
return 0
}
shift := 0
for mask&1 == 0 {
mask >>= 1
shift++
}
bits := 0
for mask != 0 {
mask >>= 1
bits++
}
val := (v >> uint(shift)) & ((1 << uint(bits)) - 1)
if bits < 8 {
return uint8(val<<(8-bits) | val>>(2*uint(bits)-8))
}
return uint8(val >> uint(bits-8))
}