From 7c3d88147f14dddd7aa1f834caf44c30c16aa090 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:04:11 +0300 Subject: [PATCH 1/7] document typed Sync payload gap in roadmap --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b14503..b6a4904 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,10 @@ resp, _ := c.Sync(ctx, user, &eas.SyncRequest{ for _, col := range resp.Collections.Collection { for _, add := range col.Commands.Add { - // add.ApplicationData → typed eas.Email + // add.ServerID identifies the message; add.ApplicationData is an + // opaque carrier in v0.x — see the "Typed Sync payloads" entry in + // the Roadmap below for the planned typed-decoding API. + _ = add.ServerID } } ``` @@ -129,6 +132,13 @@ Implemented and covered by the test suite: Out of scope for v0.x; tracked for future releases. +- **Typed `Sync` payloads**: today `SyncCollection.Commands.Add[].ApplicationData` + is `*eas.AppRaw struct{}`, i.e. an opaque carrier — concrete `Email`, + `Appointment`, `Contact` and `Task` bodies are not surfaced by `c.Sync`. + Plan: add a `wbxml.RawElement` primitive so the decoder can preserve the + raw subtree, expose `SyncAdd.Email()/Appointment()/Contact()/Task()` + helpers for mixed-class responses, and provide a generic + `eas.SyncTyped[T]` wrapper for the common single-class case. - **Commands**: `SendMail`, `SmartReply`, `SmartForward`, `MeetingResponse`, `MoveItems`, `ItemOperations` (Fetch/EmptyFolderContents), `GetItemEstimate`, `Search`, `ResolveRecipients`, `ValidateCert`, From 95cda91f0dbed1ea5dbc085d9b324ddd375ea3a3 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:28:58 +0300 Subject: [PATCH 2/7] wbxml: raw element field type for opaque payload pass-through --- internal/spec/coverage.csv | 2 + wbxml/decoder.go | 119 ++++++++++ wbxml/encoder.go | 30 +++ wbxml/marshal.go | 91 ++++++++ wbxml/marshal_extra_test.go | 424 ++++++++++++++++++++++++++++++++++++ wbxml/raw.go | 17 ++ 6 files changed, 683 insertions(+) create mode 100644 wbxml/raw.go diff --git a/internal/spec/coverage.csv b/internal/spec/coverage.csv index 4ade251..401febf 100644 --- a/internal/spec/coverage.csv +++ b/internal/spec/coverage.csv @@ -52,6 +52,8 @@ MS-ASWBXML/marshal.omitempty,MS-ASWBXML,§2.1.2.1,Marshal honours the ;omitempty MS-ASWBXML/marshal.opaque,MS-ASWBXML,§2.1.2.1,Marshal honours the ;opaque option and emits the field value as OPAQUE,required MS-ASWBXML/marshal.slice,MS-ASWBXML,§2.1.2.1,Marshal emits one element per slice entry preserving order,required MS-ASWBXML/marshal.roundtrip,MS-ASWBXML,§2.1.2.1,Marshal followed by Unmarshal reproduces the input struct verbatim including nested page changes,required +MS-ASWBXML/marshal.raw,MS-ASWBXML,§2.1.2.1,Marshal honours the ;raw option carrying RawElement bodies through round-trip,required +MS-ASWBXML/marshal.raw-page,MS-ASWBXML,§2.1.2.1,RawElement preserves active code-page state for the next sibling element,required MS-ASWBXML/decoder.fuzz,MS-ASWBXML,§2.1.2.1,Decoder must not panic on any byte input regardless of malformed structure,required MS-ASHTTP/request.path,MS-ASHTTP,§2.2.1,Request line uses POST /Microsoft-Server-ActiveSync,required MS-ASHTTP/query.base64.layout,MS-ASHTTP,§2.2.1.1.1.1,"Base64 query bytes layout: ProtocolVersion (1B); CommandCode (1B); Locale little-endian (2B); DeviceID len-prefixed; PolicyKey len-prefixed; DeviceType len-prefixed; Params",required diff --git a/wbxml/decoder.go b/wbxml/decoder.go index 2b0e466..ec3354e 100644 --- a/wbxml/decoder.go +++ b/wbxml/decoder.go @@ -2,6 +2,7 @@ package wbxml import ( "bufio" + "bytes" "errors" "fmt" "io" @@ -150,6 +151,124 @@ func (d *Decoder) NextToken() (Token, error) { } } +// Page returns the currently active code page. +func (d *Decoder) Page() byte { return d.page } + +// CaptureRaw reads bytes from the underlying stream until it consumes the +// END token that closes the element whose open tag was just returned. The +// returned slice contains everything between (but excluding) that open tag +// and the matching END token, including any inner SWITCH_PAGE markers, so it +// can be replayed verbatim by Encoder.WriteRaw. If hasContent is false the +// call is a no-op and returns a nil slice. +// +// CaptureRaw also tracks the active code page across inner SWITCH_PAGE +// transitions, so Decoder.Page reflects the page that is active immediately +// after the closing END. +// +// SPEC: MS-ASWBXML/marshal.raw +func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { + if !hasContent { + return nil, nil + } + var buf []byte + depth := 1 + for depth > 0 { + b, err := d.r.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + switch b { + case SwitchPage: + page, err := d.r.ReadByte() + if err != nil { + return nil, fmt.Errorf("wbxml: SWITCH_PAGE: %w", err) + } + if _, ok := PageByID(page); !ok { + return nil, fmt.Errorf("wbxml: SWITCH_PAGE to unknown code page %d", page) + } + d.page = page + d.pageInit = true + buf = append(buf, SwitchPage, page) + case End: + depth-- + if depth == 0 { + return buf, nil + } + buf = append(buf, End) + case StrI: + buf = append(buf, StrI) + for { + c, err := d.r.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + buf = append(buf, c) + if c == 0x00 { + break + } + } + case StrT: + buf = append(buf, StrT) + if buf, err = appendMbUint32Bytes(d.r, buf); err != nil { + return nil, err + } + case Opaque: + buf = append(buf, Opaque) + before := len(buf) + if buf, err = appendMbUint32Bytes(d.r, buf); err != nil { + return nil, err + } + n, _, perr := ReadMbUint32(bytes.NewReader(buf[before:])) + if perr != nil { + return nil, perr + } + if n > MaxOpaqueSize { + return nil, fmt.Errorf("wbxml: OPAQUE length %d exceeds %d-byte limit", n, MaxOpaqueSize) + } + payload := make([]byte, n) + if _, err := io.ReadFull(d.r, payload); err != nil { + return nil, err + } + buf = append(buf, payload...) + default: + if !d.pageInit { + return nil, errors.New("wbxml: tag byte before any SWITCH_PAGE") + } + buf = append(buf, b) + if TagHasContent(b) { + depth++ + } + } + } + return buf, nil +} + +// appendMbUint32Bytes copies an mb_u_int32 from r into dst, returning the +// extended slice. It is the byte-preserving counterpart to ReadMbUint32 and is +// used by CaptureRaw to keep STR_T offsets and OPAQUE lengths verbatim. +func appendMbUint32Bytes(r io.ByteReader, dst []byte) ([]byte, error) { + for n := 1; n <= 5; n++ { + c, err := r.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) && n > 1 { + return dst, io.ErrUnexpectedEOF + } + return dst, err + } + dst = append(dst, c) + if c&0x80 == 0 { + return dst, nil + } + } + return dst, errors.New("wbxml: mb_u_int32 longer than 5 bytes") +} + func readNulString(r io.ByteReader) (string, error) { var buf []byte for { diff --git a/wbxml/encoder.go b/wbxml/encoder.go index 82004fe..f2a624e 100644 --- a/wbxml/encoder.go +++ b/wbxml/encoder.go @@ -70,3 +70,33 @@ func (e *Encoder) Opaque(b []byte) error { _, err := e.w.Write(b) return err } + +// ForceSwitchPage emits a SWITCH_PAGE token to p and updates the encoder's +// active page. Unlike StartTag, the token is emitted unconditionally so +// callers can align the encoder state with externally produced raw bytes +// before calling WriteRaw. +func (e *Encoder) ForceSwitchPage(p byte) error { + if _, ok := PageByID(p); !ok { + return fmt.Errorf("wbxml: unknown code page %d", p) + } + if _, err := e.w.Write([]byte{SwitchPage, p}); err != nil { + return err + } + e.page = p + e.pageInit = true + return nil +} + +// WriteRaw writes b to the underlying stream verbatim. After the call the +// encoder's active page is marked as unknown, so the next StartTag is +// guaranteed to be preceded by a SWITCH_PAGE. b is expected to be a +// well-formed sequence of WBXML tokens that does not contain a trailing END +// for any caller-managed element; balancing those END tokens is the caller's +// responsibility. +func (e *Encoder) WriteRaw(b []byte) error { + if _, err := e.w.Write(b); err != nil { + return err + } + e.pageInit = false + return nil +} diff --git a/wbxml/marshal.go b/wbxml/marshal.go index 62a9c2a..056f3f9 100644 --- a/wbxml/marshal.go +++ b/wbxml/marshal.go @@ -86,6 +86,7 @@ type tagSpec struct { tagName string omitempty bool opaque bool + raw bool } type fieldSpec struct { @@ -160,14 +161,21 @@ func parseTag(raw string) (tagSpec, error) { out.omitempty = true case "opaque": out.opaque = true + case "raw": + out.raw = true case "": default: return tagSpec{}, fmt.Errorf("unknown wbxml option %q", opt) } } + if out.raw && out.opaque { + return tagSpec{}, fmt.Errorf("wbxml options ,raw and ,opaque are mutually exclusive in %q", raw) + } return out, nil } +var rawElementType = reflect.TypeOf(RawElement{}) + // ------- encoding ------- func encodeStruct(enc *Encoder, v reflect.Value, info *structInfo, self *tagSpec) error { @@ -194,6 +202,9 @@ func encodeStruct(enc *Encoder, v reflect.Value, info *structInfo, self *tagSpec } func encodeField(enc *Encoder, v reflect.Value, fs *fieldSpec) error { + if fs.raw { + return encodeRawField(enc, v, fs) + } // Slices of struct or scalar produce one element per entry. if v.Kind() == reflect.Slice && v.Type().Elem().Kind() != reflect.Uint8 { for i := 0; i < v.Len(); i++ { @@ -400,6 +411,9 @@ func decodeStruct(dec *Decoder, v reflect.Value, info *structInfo, hasContent bo } func decodeField(dec *Decoder, fv reflect.Value, fs *fieldSpec, openTok Token) error { + if fs.raw { + return decodeRawField(dec, fv, fs, openTok) + } if fv.Kind() == reflect.Slice && fv.Type().Elem().Kind() != reflect.Uint8 { elemType := fv.Type().Elem() isPtr := elemType.Kind() == reflect.Pointer @@ -550,6 +564,83 @@ func assignScalar(v reflect.Value, s string) error { return nil } +// encodeRawField emits a *RawElement (or RawElement) field. The element body +// is re-aligned to RawElement.Page via Encoder.ForceSwitchPage so that the +// raw bytes can be written verbatim regardless of which page was active +// before. After the body the encoder's page is marked as unknown by +// Encoder.WriteRaw, so the next sibling is preceded by a SWITCH_PAGE. +func encodeRawField(enc *Encoder, v reflect.Value, fs *fieldSpec) error { + var raw *RawElement + switch v.Kind() { + case reflect.Pointer: + if v.Type().Elem() != rawElementType { + return fmt.Errorf("wbxml: ,raw field %s.%s must be *RawElement, got %s", fs.pageName, fs.tagName, v.Type()) + } + if v.IsNil() { + if fs.omitempty { + return nil + } + return enc.StartTag(fs.page, fs.identity, false, false) + } + raw = v.Interface().(*RawElement) + case reflect.Struct: + if v.Type() != rawElementType { + return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement, got %s", fs.pageName, fs.tagName, v.Type()) + } + raw = v.Addr().Interface().(*RawElement) + default: + return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement or *RawElement, got %s", fs.pageName, fs.tagName, v.Type()) + } + if len(raw.Bytes) == 0 { + if fs.omitempty { + return nil + } + return enc.StartTag(fs.page, fs.identity, false, false) + } + if err := enc.StartTag(fs.page, fs.identity, false, true); err != nil { + return err + } + // Align the encoder's active page with raw.Page only when needed: emitting + // an unconditional SWITCH_PAGE would prevent byte-identity round trips for + // RawElement values where the active page was already correct. + if !enc.pageInit || enc.page != raw.Page { + if err := enc.ForceSwitchPage(raw.Page); err != nil { + return err + } + } + if err := enc.WriteRaw(raw.Bytes); err != nil { + return err + } + return enc.EndTag() +} + +// decodeRawField captures the verbatim wire content of an open element into +// a *RawElement (or RawElement) field. The active code page at the moment of +// the open tag is recorded in RawElement.Page. +func decodeRawField(dec *Decoder, fv reflect.Value, fs *fieldSpec, openTok Token) error { + page := openTok.Page + body, err := dec.CaptureRaw(openTok.HasContent) + if err != nil { + return err + } + switch fv.Kind() { + case reflect.Pointer: + if fv.Type().Elem() != rawElementType { + return fmt.Errorf("wbxml: ,raw field %s.%s must be *RawElement, got %s", fs.pageName, fs.tagName, fv.Type()) + } + fv.Set(reflect.ValueOf(&RawElement{Page: page, Bytes: body})) + return nil + case reflect.Struct: + if fv.Type() != rawElementType { + return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement, got %s", fs.pageName, fs.tagName, fv.Type()) + } + fv.Set(reflect.ValueOf(RawElement{Page: page, Bytes: body})) + return nil + default: + return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement or *RawElement, got %s", fs.pageName, fs.tagName, fv.Type()) + } +} + func skipElement(dec *Decoder, hasContent bool) error { if !hasContent { return nil diff --git a/wbxml/marshal_extra_test.go b/wbxml/marshal_extra_test.go index c9b5aa8..b230d05 100644 --- a/wbxml/marshal_extra_test.go +++ b/wbxml/marshal_extra_test.go @@ -2,6 +2,7 @@ package wbxml import ( "bytes" + "io" "reflect" "testing" ) @@ -558,3 +559,426 @@ func TestIsZero_MapAndPointer(t *testing.T) { t.Fatal("nil pointer not zero") } } + +type rawOuter struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + SyncKey string `wbxml:"AirSync.SyncKey"` + AppData *RawElement `wbxml:"AirSync.ApplicationData,omitempty,raw"` +} + +type rawAppData struct { + XMLName struct{} `wbxml:"AirSync.ApplicationData"` + Subject string `wbxml:"Email.Subject"` +} + +// emailBodyBytes returns the WBXML body of an Email-class ApplicationData +// element containing a single Subject child. The bytes are produced as if the +// encoder were already in PageAirSync (ApplicationData's own page); the +// leading SWITCH_PAGE Email transition required to interpret Subject is part +// of the returned slice. +func emailBodyBytes(t *testing.T, subject string) []byte { + t.Helper() + var w bytes.Buffer + e := NewEncoder(&w) + if err := e.StartTag(PageEmail, 0x14, false, true); err != nil { // Email.Subject + t.Fatalf("StartTag: %v", err) + } + if err := e.StrI(subject); err != nil { + t.Fatalf("StrI: %v", err) + } + if err := e.EndTag(); err != nil { + t.Fatalf("EndTag: %v", err) + } + return w.Bytes() +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestMarshal_RawElement_RoundTrip(t *testing.T) { + body := emailBodyBytes(t, "hello") + in := rawOuter{ + SyncKey: "1", + AppData: &RawElement{Page: PageAirSync, Bytes: body}, + } + data, err := Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var out rawOuter + if err := Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.AppData == nil { + t.Fatal("RawElement is nil after round-trip") + } + if out.AppData.Page != PageAirSync { + t.Fatalf("RawElement.Page mismatch: got %d want %d", out.AppData.Page, PageAirSync) + } + if !bytes.Equal(out.AppData.Bytes, body) { + t.Fatalf("RawElement.Bytes mismatch:\n got %x\nwant %x", out.AppData.Bytes, body) + } + + data2, err := Marshal(&out) + if err != nil { + t.Fatalf("Marshal(out): %v", err) + } + if !bytes.Equal(data, data2) { + t.Fatalf("re-marshal not byte-identical:\n got %x\nwant %x", data2, data) + } + + var rebuilt struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + SyncKey string `wbxml:"AirSync.SyncKey"` + AppData *rawAppData `wbxml:"AirSync.ApplicationData,omitempty"` + } + if err := Unmarshal(data, &rebuilt); err != nil { + t.Fatalf("Unmarshal rebuilt: %v", err) + } + if rebuilt.AppData == nil || rebuilt.AppData.Subject != "hello" { + t.Fatalf("typed re-decode mismatch: %+v", rebuilt.AppData) + } +} + +type rawWithSibling struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + SyncKey string `wbxml:"AirSync.SyncKey"` + AppData *RawElement `wbxml:"AirSync.ApplicationData,raw"` + Status int32 `wbxml:"AirSync.Status"` +} + +// SPEC: MS-ASWBXML/marshal.raw-page +func TestMarshal_RawElement_PreservesInnerSwitchPage(t *testing.T) { + body := emailBodyBytes(t, "x") + in := rawWithSibling{ + SyncKey: "1", + AppData: &RawElement{Page: PageAirSync, Bytes: body}, + Status: 1, + } + data, err := Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + statusTag := EncodeTag(0x0E, false, true) // AirSync.Status with content bit + if !bytes.Contains(data, []byte{SwitchPage, PageAirSync, statusTag}) { + t.Fatalf("expected SWITCH_PAGE AirSync before Status open tag in %x", data) + } + + var out rawWithSibling + if err := Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.Status != 1 { + t.Fatalf("Status not preserved: %d", out.Status) + } + if !bytes.Equal(out.AppData.Bytes, body) { + t.Fatalf("body mismatch") + } +} + +type rawSimple struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + AppData *RawElement `wbxml:"AirSync.ApplicationData,raw"` +} + +type rawByValue struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + AppData RawElement `wbxml:"AirSync.ApplicationData,raw"` +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestMarshal_RawElement_OmitEmpty(t *testing.T) { + in := rawOuter{SyncKey: "1"} + data, err := Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if bytes.Contains(data, []byte{0x5D}) { // ApplicationData with content bit + t.Fatalf("ApplicationData unexpectedly present in %x", data) + } + + in2 := rawOuter{SyncKey: "1", AppData: &RawElement{Page: PageAirSync}} + data2, err := Marshal(&in2) + if err != nil { + t.Fatalf("Marshal omitempty empty: %v", err) + } + if !bytes.Equal(data, data2) { + t.Fatalf("non-nil empty RawElement should be omitted with omitempty: got %x want %x", data2, data) + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestMarshal_RawElement_NilWithoutOmitEmpty(t *testing.T) { + data, err := Marshal(&rawSimple{}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + apd := byte(0x1D) // ApplicationData identity, no content bit + if !bytes.Contains(data, []byte{apd}) { + t.Fatalf("expected empty ApplicationData tag, got %x", data) + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestMarshal_RawElement_ByValue(t *testing.T) { + body := emailBodyBytes(t, "v") + in := rawByValue{AppData: RawElement{Page: PageAirSync, Bytes: body}} + data, err := Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var out rawByValue + if err := Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if !bytes.Equal(out.AppData.Bytes, body) { + t.Fatalf("body mismatch") + } +} + +type rawWrongPtrType struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + AppData *rawByValue `wbxml:"AirSync.ApplicationData,raw"` +} + +type rawWrongValueType struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + AppData rawByValue `wbxml:"AirSync.ApplicationData,raw"` +} + +type rawWrongScalar struct { + XMLName struct{} `wbxml:"AirSync.Sync"` + AppData int `wbxml:"AirSync.ApplicationData,raw"` +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestMarshal_RawElement_WrongTypes(t *testing.T) { + if _, err := Marshal(&rawWrongPtrType{AppData: &rawByValue{}}); err == nil { + t.Fatal("expected error for *non-RawElement field tagged raw") + } + if _, err := Marshal(&rawWrongValueType{}); err == nil { + t.Fatal("expected error for struct-non-RawElement field tagged raw") + } + if _, err := Marshal(&rawWrongScalar{AppData: 1}); err == nil { + t.Fatal("expected error for scalar field tagged raw") + } +} + +// SPEC: MS-ASWBXML/marshal.raw-page +func TestMarshal_RawElement_NonZeroPageEmitsForceSwitch(t *testing.T) { + body := []byte{EncodeTag(0x14, false, true), StrI, 'h', 'i', 0x00, End} // Email.Subject = hi + in := rawOuter{ + SyncKey: "1", + AppData: &RawElement{Page: PageEmail, Bytes: body}, + } + data, err := Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !bytes.Contains(data, []byte{SwitchPage, PageEmail}) { + t.Fatalf("expected SWITCH_PAGE Email in %x", data) + } + var out rawOuter + if err := Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.AppData == nil || out.AppData.Page != PageAirSync { + t.Fatalf("Page mismatch: %+v", out.AppData) + } + expectedBody := append([]byte{SwitchPage, PageEmail}, body...) + if !bytes.Equal(out.AppData.Bytes, expectedBody) { + t.Fatalf("body mismatch:\n got %x\nwant %x", out.AppData.Bytes, expectedBody) + } +} + +// SPEC: OMA-WBXML-1.3/global.SWITCH_PAGE +func TestEncoder_ForceSwitchPage_UnknownPage(t *testing.T) { + e := NewEncoder(&bytes.Buffer{}) + if err := e.ForceSwitchPage(0xFF); err == nil { + t.Fatal("expected unknown-page error") + } +} + +type errWriter struct{ err error } + +func (w *errWriter) Write(p []byte) (int, error) { return 0, w.err } + +// SPEC: OMA-WBXML-1.3/global.SWITCH_PAGE +func TestEncoder_ForceSwitchPage_WriteError(t *testing.T) { + e := NewEncoder(&errWriter{err: io.ErrShortWrite}) + if err := e.ForceSwitchPage(PageEmail); err == nil { + t.Fatal("expected write error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestEncoder_WriteRaw_WriteError(t *testing.T) { + e := NewEncoder(&errWriter{err: io.ErrShortWrite}) + if err := e.WriteRaw([]byte{0x01}); err == nil { + t.Fatal("expected write error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_Page(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{SwitchPage, PageEmail, 0x40})) + if _, err := d.NextToken(); err != nil { + t.Fatalf("NextToken: %v", err) + } + if d.Page() != PageEmail { + t.Fatalf("Page=%d want %d", d.Page(), PageEmail) + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_NoContent(t *testing.T) { + d := NewDecoder(bytes.NewReader(nil)) + body, err := d.CaptureRaw(false) + if err != nil || body != nil { + t.Fatalf("CaptureRaw(false)=%x,%v; want nil,nil", body, err) + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_TruncatedTag(t *testing.T) { + d := NewDecoder(bytes.NewReader(nil)) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected EOF error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_TruncatedSwitchPage(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{SwitchPage})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected SWITCH_PAGE error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_UnknownPage(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{SwitchPage, 0xFF})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected unknown page error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_TruncatedStrI(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{StrI, 'a'})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected STR_I truncation error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_StrTAndOpaque(t *testing.T) { + // StrT 0x83 + offset 5 (mb_u_int32 single byte), then END for outer. + d := NewDecoder(bytes.NewReader([]byte{StrT, 0x05, End})) + body, err := d.CaptureRaw(true) + if err != nil { + t.Fatalf("CaptureRaw: %v", err) + } + if !bytes.Equal(body, []byte{StrT, 0x05}) { + t.Fatalf("body=%x want StrT 05", body) + } + d2 := NewDecoder(bytes.NewReader([]byte{Opaque, 0x02, 0xAA, 0xBB, End})) + body2, err := d2.CaptureRaw(true) + if err != nil { + t.Fatalf("CaptureRaw opaque: %v", err) + } + if !bytes.Equal(body2, []byte{Opaque, 0x02, 0xAA, 0xBB}) { + t.Fatalf("body=%x", body2) + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_TruncatedOpaqueLength(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{Opaque, 0x80})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected OPAQUE length error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_OpaqueExceedsLimit(t *testing.T) { + defer swapMaxOpaqueSize(2)() + d := NewDecoder(bytes.NewReader([]byte{Opaque, 0x05, 1, 2, 3, 4, 5, End})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected OPAQUE limit error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_TruncatedOpaquePayload(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{Opaque, 0x05, 1, 2})) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected truncation error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_BadTagBeforeSwitchPage(t *testing.T) { + d := NewDecoder(bytes.NewReader([]byte{0x40})) + d.pageInit = false + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected error for tag before SWITCH_PAGE") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestDecodeRawField_TruncatedBody(t *testing.T) { + // Build a Marshal-like wire: header, Sync open with content, ApplicationData + // open with content, then EOF (truncation inside CaptureRaw). + var w bytes.Buffer + enc := NewEncoder(&w) + if err := enc.WriteHeader(Header{Version: 0x03, PublicID: 0x01, Charset: 0x6A}); err != nil { + t.Fatal(err) + } + if err := enc.StartTag(PageAirSync, 0x05, false, true); err != nil { // AirSync.Sync + t.Fatal(err) + } + if err := enc.StartTag(PageAirSync, 0x1D, false, true); err != nil { // ApplicationData + t.Fatal(err) + } + if err := Unmarshal(w.Bytes(), &rawSimple{}); err == nil { + t.Fatal("expected truncation error") + } +} + +// SPEC: OMA-WBXML-1.3/mb_u_int32.encoding +func TestAppendMbUint32Bytes_Overlong(t *testing.T) { + r := bytes.NewReader([]byte{0x80, 0x80, 0x80, 0x80, 0x80, 0x80}) + if _, err := appendMbUint32Bytes(r, nil); err == nil { + t.Fatal("expected overlong error") + } +} + +// SPEC: OMA-WBXML-1.3/mb_u_int32.encoding +func TestAppendMbUint32Bytes_EOFMidStream(t *testing.T) { + r := bytes.NewReader([]byte{0x80}) + if _, err := appendMbUint32Bytes(r, nil); err == nil { + t.Fatal("expected EOF error") + } +} + +// SPEC: OMA-WBXML-1.3/mb_u_int32.encoding +func TestAppendMbUint32Bytes_FirstByteEOF(t *testing.T) { + r := bytes.NewReader(nil) + if _, err := appendMbUint32Bytes(r, nil); err == nil { + t.Fatal("expected EOF error") + } +} + +// SPEC: MS-ASWBXML/marshal.raw +func TestParseTag_RawOption(t *testing.T) { + spec, err := parseTag("AirSync.ApplicationData,raw") + if err != nil { + t.Fatalf("parseTag(raw): %v", err) + } + if !spec.raw { + t.Fatal("raw option not set") + } + if _, err := parseTag("AirSync.ApplicationData,raw,opaque"); err == nil { + t.Fatal("expected error for raw+opaque combination") + } +} diff --git a/wbxml/raw.go b/wbxml/raw.go new file mode 100644 index 0000000..bfb9a21 --- /dev/null +++ b/wbxml/raw.go @@ -0,0 +1,17 @@ +package wbxml + +// RawElement carries the verbatim wire content of a single WBXML element. +// +// It is intended for protocol layers (notably MS-ASCMD Sync.ApplicationData) +// where the wrapper element is fixed but the inner payload is class-dependent +// and decoded later by the caller. Bytes holds everything that appears between +// the element's open tag and matching END token, exclusive on both ends, but +// including any inner SWITCH_PAGE markers needed to replay the bytes +// verbatim. Page captures the active code page at the moment the open tag was +// emitted, so an encoder can re-establish that state before WriteRaw. +// +// SPEC: MS-ASWBXML/marshal.raw +type RawElement struct { + Page byte + Bytes []byte +} From 19bf69b0ac215d8ce5abcae78ba22459f52d3998 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:36:39 +0300 Subject: [PATCH 3/7] eas: typed Sync ApplicationData helpers BREAKING: SyncAdd.ApplicationData and SyncChange.ApplicationData are now *wbxml.RawElement instead of the opaque *AppRaw, so callers can recover the original wire bytes and decode them into Email/Appointment/Contact/Task via UnmarshalApplicationData[T] or the four convenience methods on SyncAdd / SyncChange. --- client/integration_test.go | 104 ++++++++++++++++++ eas/airsync.go | 26 ++--- eas/sync_helpers.go | 127 ++++++++++++++++++++++ eas/sync_helpers_test.go | 210 +++++++++++++++++++++++++++++++++++++ internal/spec/coverage.csv | 2 + 5 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 eas/sync_helpers.go create mode 100644 eas/sync_helpers_test.go diff --git a/client/integration_test.go b/client/integration_test.go index efa6c97..3526cfe 100644 --- a/client/integration_test.go +++ b/client/integration_test.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "context" "io" "net/http" @@ -270,6 +271,109 @@ func TestSync_RoundTrip(t *testing.T) { } } +// SPEC: MS-ASCMD/sync.applicationdata.raw +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestSync_TypedApplicationData(t *testing.T) { + emailIn := &eas.Email{ + Subject: "hello", + From: "alice@example.com", + To: "bob@example.com", + } + body := mustEmailBody(t, emailIn) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req eas.SyncRequest + decodeWBXML(t, r, &req) + writeWBXML(t, w, &eas.SyncResponse{ + Collections: eas.SyncCollections{ + Collection: []eas.SyncCollection{{ + SyncKey: "S-1", + CollectionID: "1", + Class: "Email", + Status: int32(eas.SyncStatusSuccess), + Commands: &eas.SyncCommands{ + Add: []eas.SyncAdd{{ + ServerID: "1:1", + ApplicationData: &wbxml.RawElement{ + Page: wbxml.PageAirSync, + Bytes: body, + }, + }}, + Change: []eas.SyncChange{{ + ServerID: "1:1", + ApplicationData: &wbxml.RawElement{ + Page: wbxml.PageAirSync, + Bytes: body, + }, + }}, + }, + }}, + }, + }) + })) + t.Cleanup(srv.Close) + + c := newTestClient(t, srv) + resp, err := c.Sync(context.Background(), "user@example.com", &eas.SyncRequest{ + Collections: eas.SyncCollections{ + Collection: []eas.SyncCollection{{SyncKey: "0", CollectionID: "1", GetChanges: 1}}, + }, + }) + if err != nil { + t.Fatalf("Sync: %v", err) + } + col := resp.Collections.Collection[0] + if col.Commands == nil || len(col.Commands.Add) != 1 { + t.Fatalf("missing Add: %+v", col.Commands) + } + add := col.Commands.Add[0] + if add.ApplicationData == nil { + t.Fatal("ApplicationData missing on Add") + } + got, err := add.Email() + if err != nil { + t.Fatalf("add.Email: %v", err) + } + if got.Subject != emailIn.Subject || got.From != emailIn.From || got.To != emailIn.To { + t.Fatalf("Email decode mismatch: %+v", got) + } + chg := col.Commands.Change[0] + got2, err := chg.Email() + if err != nil { + t.Fatalf("change.Email: %v", err) + } + if got2.Subject != emailIn.Subject { + t.Fatalf("change Email mismatch: %+v", got2) + } +} + +// mustEmailBody marshals an Email value and strips the WBXML header plus +// the AirSync.ApplicationData wrapper, returning just the bytes that would +// appear inside ApplicationData on the wire. +func mustEmailBody(t *testing.T, e *eas.Email) []byte { + t.Helper() + data, err := wbxml.Marshal(e) + if err != nil { + t.Fatalf("marshal email: %v", err) + } + dec := wbxml.NewDecoder(bytes.NewReader(data)) + if _, err := dec.ReadHeader(); err != nil { + t.Fatalf("read header: %v", err) + } + tok, err := dec.NextToken() + if err != nil { + t.Fatalf("next token: %v", err) + } + if tok.Kind != wbxml.KindTag { + t.Fatalf("expected tag, got %s", tok.Kind) + } + body, err := dec.CaptureRaw(tok.HasContent) + if err != nil { + t.Fatalf("capture raw: %v", err) + } + return body +} + // SPEC: MS-ASCMD/ping.response // SPEC: MS-ASCMD/ping.status.changes func TestPing_ChangesAvailable(t *testing.T) { diff --git a/eas/airsync.go b/eas/airsync.go index de12a2b..68c73db 100644 --- a/eas/airsync.go +++ b/eas/airsync.go @@ -1,5 +1,7 @@ package eas +import "github.com/remdev/go-activesync/wbxml" + // SyncRequest is the MS-ASCMD Sync command request payload. type SyncRequest struct { XMLName struct{} `wbxml:"AirSync.Sync"` @@ -60,16 +62,22 @@ type SyncCommands struct { } // SyncAdd carries a server-pushed addition or a client-side new item. +// +// ApplicationData is left as a raw WBXML element because the concrete payload +// type (Email, Appointment, Contact, Task, …) depends on the collection's +// Class. Use the convenience methods on SyncAdd / SyncChange (Email, +// Appointment, Contact, Task) or UnmarshalApplicationData[T] to decode the +// payload into a typed value. type SyncAdd struct { - ServerID string `wbxml:"AirSync.ServerId,omitempty"` - ClientID string `wbxml:"AirSync.ClientId,omitempty"` - ApplicationData *AppRaw `wbxml:"AirSync.ApplicationData,omitempty"` + ServerID string `wbxml:"AirSync.ServerId,omitempty"` + ClientID string `wbxml:"AirSync.ClientId,omitempty"` + ApplicationData *wbxml.RawElement `wbxml:"AirSync.ApplicationData,omitempty,raw"` } // SyncChange carries an item modification. type SyncChange struct { - ServerID string `wbxml:"AirSync.ServerId"` - ApplicationData *AppRaw `wbxml:"AirSync.ApplicationData,omitempty"` + ServerID string `wbxml:"AirSync.ServerId"` + ApplicationData *wbxml.RawElement `wbxml:"AirSync.ApplicationData,omitempty,raw"` } // SyncDelete carries an item deletion notification. @@ -81,11 +89,3 @@ type SyncDelete struct { type SyncFetch struct { ServerID string `wbxml:"AirSync.ServerId"` } - -// AppRaw is an opaque carrier for AirSync.ApplicationData. -// -// Real domain types (Email/Appointment/Contact/Task) marshal independently -// from AirSync.ApplicationData; for command-level round trips we only need -// the wrapper element to be preserved. Concrete decoding is done by the -// caller after extracting the raw bytes if needed. -type AppRaw struct{} diff --git a/eas/sync_helpers.go b/eas/sync_helpers.go new file mode 100644 index 0000000..d918834 --- /dev/null +++ b/eas/sync_helpers.go @@ -0,0 +1,127 @@ +package eas + +import ( + "bytes" + "errors" + "fmt" + + "github.com/remdev/go-activesync/wbxml" +) + +// applicationDataIdentity is the AirSync.ApplicationData tag identity (0x1D). +// The wrapper used by UnmarshalApplicationData re-emits this element so the +// captured raw body can be parsed by domain types whose root XMLName is +// AirSync.ApplicationData (Email, Appointment, Contact, Task, …). +const applicationDataIdentity byte = 0x1D + +// ErrEmptyApplicationData is returned when a typed Sync helper is asked to +// decode a SyncAdd / SyncChange whose ApplicationData element was either +// missing or carried an empty body. +var ErrEmptyApplicationData = errors.New("eas: ApplicationData is empty") + +// UnmarshalApplicationData decodes the body bytes carried by a *wbxml.RawElement +// captured from a SyncAdd / SyncChange into a typed value of T. The caller is +// responsible for picking the right T for the collection's Class (e.g. +// *Email for "Email", *Appointment for "Calendar"); a wrong choice surfaces +// as a wbxml decoding error, never as a panic. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func UnmarshalApplicationData[T any](raw *wbxml.RawElement) (*T, error) { + if raw == nil || len(raw.Bytes) == 0 { + return nil, ErrEmptyApplicationData + } + wrapper, err := buildApplicationDataWrapper(raw) + if err != nil { + return nil, err + } + out := new(T) + if err := wbxml.Unmarshal(wrapper, out); err != nil { + return nil, fmt.Errorf("eas: decode ApplicationData: %w", err) + } + return out, nil +} + +// buildApplicationDataWrapper produces a complete WBXML 1.3 document of the +// shape: +// +// raw.Bytes +// +// with the encoder's active page aligned to raw.Page just before the body so +// the captured bytes can be replayed verbatim regardless of which page they +// originally referenced. Writes target a bytes.Buffer, which never returns +// an error; only ForceSwitchPage can fail, when raw.Page is unknown. +func buildApplicationDataWrapper(raw *wbxml.RawElement) ([]byte, error) { + var buf bytes.Buffer + enc := wbxml.NewEncoder(&buf) + var firstErr error + push := func(err error) { + if err != nil && firstErr == nil { + firstErr = err + } + } + push(enc.WriteHeader(wbxml.Header{Version: 0x03, PublicID: 0x01, Charset: 0x6A})) + push(enc.StartTag(wbxml.PageAirSync, applicationDataIdentity, false, true)) + push(enc.ForceSwitchPage(raw.Page)) + push(enc.WriteRaw(raw.Bytes)) + push(enc.EndTag()) + if firstErr != nil { + return nil, firstErr + } + return buf.Bytes(), nil +} + +// Email decodes the SyncAdd's ApplicationData as an *Email. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (a SyncAdd) Email() (*Email, error) { + return UnmarshalApplicationData[Email](a.ApplicationData) +} + +// Appointment decodes the SyncAdd's ApplicationData as an *Appointment. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (a SyncAdd) Appointment() (*Appointment, error) { + return UnmarshalApplicationData[Appointment](a.ApplicationData) +} + +// Contact decodes the SyncAdd's ApplicationData as a *Contact. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (a SyncAdd) Contact() (*Contact, error) { + return UnmarshalApplicationData[Contact](a.ApplicationData) +} + +// Task decodes the SyncAdd's ApplicationData as a *Task. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (a SyncAdd) Task() (*Task, error) { + return UnmarshalApplicationData[Task](a.ApplicationData) +} + +// Email decodes the SyncChange's ApplicationData as an *Email. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (c SyncChange) Email() (*Email, error) { + return UnmarshalApplicationData[Email](c.ApplicationData) +} + +// Appointment decodes the SyncChange's ApplicationData as an *Appointment. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (c SyncChange) Appointment() (*Appointment, error) { + return UnmarshalApplicationData[Appointment](c.ApplicationData) +} + +// Contact decodes the SyncChange's ApplicationData as a *Contact. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (c SyncChange) Contact() (*Contact, error) { + return UnmarshalApplicationData[Contact](c.ApplicationData) +} + +// Task decodes the SyncChange's ApplicationData as a *Task. +// +// SPEC: MS-ASCMD/sync.applicationdata.typed +func (c SyncChange) Task() (*Task, error) { + return UnmarshalApplicationData[Task](c.ApplicationData) +} diff --git a/eas/sync_helpers_test.go b/eas/sync_helpers_test.go new file mode 100644 index 0000000..dfd5304 --- /dev/null +++ b/eas/sync_helpers_test.go @@ -0,0 +1,210 @@ +package eas + +import ( + "bytes" + "errors" + "testing" + + "github.com/remdev/go-activesync/wbxml" +) + +// captureApplicationDataBody marshals the given value (whose XMLName must be +// AirSync.ApplicationData) and returns just the body bytes that appear +// between the open ApplicationData tag and the matching END token, suitable +// for use as RawElement.Bytes. +func captureApplicationDataBody(t *testing.T, v any) []byte { + t.Helper() + data, err := wbxml.Marshal(v) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + dec := wbxml.NewDecoder(bytes.NewReader(data)) + if _, err := dec.ReadHeader(); err != nil { + t.Fatalf("ReadHeader: %v", err) + } + tok, err := dec.NextToken() + if err != nil { + t.Fatalf("NextToken: %v", err) + } + body, err := dec.CaptureRaw(tok.HasContent) + if err != nil { + t.Fatalf("CaptureRaw: %v", err) + } + return body +} + +// SPEC: MS-ASCMD/sync.applicationdata.raw +func TestSyncAdd_ChangeApplicationDataIsRawElement(t *testing.T) { + in := SyncResponse{ + Collections: SyncCollections{Collection: []SyncCollection{{ + SyncKey: "S-1", + CollectionID: "1", + Status: int32(SyncStatusSuccess), + Commands: &SyncCommands{ + Add: []SyncAdd{{ + ServerID: "1", + ApplicationData: &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: captureApplicationDataBody(t, &Email{Subject: "x"})}, + }}, + Change: []SyncChange{{ + ServerID: "1", + ApplicationData: &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: captureApplicationDataBody(t, &Email{Subject: "x"})}, + }}, + }, + }}}, + } + data, err := wbxml.Marshal(&in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var out SyncResponse + if err := wbxml.Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + cmd := out.Collections.Collection[0].Commands + if cmd == nil || len(cmd.Add) != 1 || cmd.Add[0].ApplicationData == nil { + t.Fatalf("Add ApplicationData missing: %+v", cmd) + } + if len(cmd.Change) != 1 || cmd.Change[0].ApplicationData == nil { + t.Fatalf("Change ApplicationData missing: %+v", cmd) + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestSyncAdd_TypedHelpers(t *testing.T) { + emailBody := captureApplicationDataBody(t, &Email{Subject: "hi", From: "a@b"}) + apptBody := captureApplicationDataBody(t, &Appointment{Subject: "meet"}) + contactBody := captureApplicationDataBody(t, &Contact{FirstName: "A", LastName: "B"}) + taskBody := captureApplicationDataBody(t, &Task{Subject: "todo"}) + + page := wbxml.PageAirSync + tests := []struct { + name string + body []byte + fn func(SyncAdd) (any, error) + want any + }{ + { + name: "email", + body: emailBody, + fn: func(a SyncAdd) (any, error) { return a.Email() }, + want: &Email{Subject: "hi", From: "a@b"}, + }, + { + name: "appointment", + body: apptBody, + fn: func(a SyncAdd) (any, error) { return a.Appointment() }, + want: &Appointment{Subject: "meet"}, + }, + { + name: "contact", + body: contactBody, + fn: func(a SyncAdd) (any, error) { return a.Contact() }, + want: &Contact{FirstName: "A", LastName: "B"}, + }, + { + name: "task", + body: taskBody, + fn: func(a SyncAdd) (any, error) { return a.Task() }, + want: &Task{Subject: "todo"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + add := SyncAdd{ApplicationData: &wbxml.RawElement{Page: page, Bytes: tc.body}} + got, err := tc.fn(add) + if err != nil { + t.Fatalf("decode %s: %v", tc.name, err) + } + if !equalApplicationData(t, got, tc.want) { + t.Fatalf("decode %s mismatch: got %+v want %+v", tc.name, got, tc.want) + } + }) + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestSyncChange_TypedHelpers(t *testing.T) { + page := wbxml.PageAirSync + chg := SyncChange{ + ApplicationData: &wbxml.RawElement{ + Page: page, + Bytes: captureApplicationDataBody(t, &Email{Subject: "z"}), + }, + } + if got, err := chg.Email(); err != nil || got.Subject != "z" { + t.Fatalf("Email: %+v err=%v", got, err) + } + chg.ApplicationData = &wbxml.RawElement{Page: page, Bytes: captureApplicationDataBody(t, &Appointment{Subject: "m"})} + if got, err := chg.Appointment(); err != nil || got.Subject != "m" { + t.Fatalf("Appointment: %+v err=%v", got, err) + } + chg.ApplicationData = &wbxml.RawElement{Page: page, Bytes: captureApplicationDataBody(t, &Contact{FirstName: "f"})} + if got, err := chg.Contact(); err != nil || got.FirstName != "f" { + t.Fatalf("Contact: %+v err=%v", got, err) + } + chg.ApplicationData = &wbxml.RawElement{Page: page, Bytes: captureApplicationDataBody(t, &Task{Subject: "tt"})} + if got, err := chg.Task(); err != nil || got.Subject != "tt" { + t.Fatalf("Task: %+v err=%v", got, err) + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestUnmarshalApplicationData_Empty(t *testing.T) { + if _, err := UnmarshalApplicationData[Email](nil); !errors.Is(err, ErrEmptyApplicationData) { + t.Fatalf("nil raw: want ErrEmptyApplicationData, got %v", err) + } + if _, err := UnmarshalApplicationData[Email](&wbxml.RawElement{}); !errors.Is(err, ErrEmptyApplicationData) { + t.Fatalf("empty raw: want ErrEmptyApplicationData, got %v", err) + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestUnmarshalApplicationData_DecodeError(t *testing.T) { + raw := &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: []byte{0xFF, 0xFF}} + if _, err := UnmarshalApplicationData[Email](raw); err == nil { + t.Fatal("expected decode error for malformed body") + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestUnmarshalApplicationData_UnknownPage(t *testing.T) { + raw := &wbxml.RawElement{Page: 0xFF, Bytes: []byte{0x01}} + if _, err := UnmarshalApplicationData[Email](raw); err == nil { + t.Fatal("expected wrapper error for unknown page") + } +} + +// SPEC: MS-ASCMD/sync.applicationdata.typed +func TestUnmarshalApplicationData_WrongType(t *testing.T) { + body := captureApplicationDataBody(t, &Email{Subject: "x"}) + raw := &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: body} + // Decoding an Email body into a Task is permissive (unknown tags are + // skipped), but the resulting Task carries no fields from the email. + got, err := UnmarshalApplicationData[Task](raw) + if err != nil { + t.Fatalf("UnmarshalApplicationData[Task]: %v", err) + } + if got.Subject != "" { + t.Fatalf("expected empty Task, got %+v", got) + } +} + +func equalApplicationData(t *testing.T, got, want any) bool { + t.Helper() + switch w := want.(type) { + case *Email: + g, ok := got.(*Email) + return ok && g.Subject == w.Subject && g.From == w.From && g.To == w.To + case *Appointment: + g, ok := got.(*Appointment) + return ok && g.Subject == w.Subject + case *Contact: + g, ok := got.(*Contact) + return ok && g.FirstName == w.FirstName && g.LastName == w.LastName + case *Task: + g, ok := got.(*Task) + return ok && g.Subject == w.Subject + } + t.Fatalf("unsupported type %T", want) + return false +} diff --git a/internal/spec/coverage.csv b/internal/spec/coverage.csv index 401febf..8c44a3f 100644 --- a/internal/spec/coverage.csv +++ b/internal/spec/coverage.csv @@ -54,6 +54,8 @@ MS-ASWBXML/marshal.slice,MS-ASWBXML,§2.1.2.1,Marshal emits one element per slic MS-ASWBXML/marshal.roundtrip,MS-ASWBXML,§2.1.2.1,Marshal followed by Unmarshal reproduces the input struct verbatim including nested page changes,required MS-ASWBXML/marshal.raw,MS-ASWBXML,§2.1.2.1,Marshal honours the ;raw option carrying RawElement bodies through round-trip,required MS-ASWBXML/marshal.raw-page,MS-ASWBXML,§2.1.2.1,RawElement preserves active code-page state for the next sibling element,required +MS-ASCMD/sync.applicationdata.raw,MS-ASCMD,§2.2.2.20,Sync ApplicationData is carried as an opaque RawElement in SyncAdd and SyncChange,required +MS-ASCMD/sync.applicationdata.typed,MS-ASCMD,§2.2.2.20,Typed convenience helpers decode ApplicationData into Email/Appointment/Contact/Task,required MS-ASWBXML/decoder.fuzz,MS-ASWBXML,§2.1.2.1,Decoder must not panic on any byte input regardless of malformed structure,required MS-ASHTTP/request.path,MS-ASHTTP,§2.2.1,Request line uses POST /Microsoft-Server-ActiveSync,required MS-ASHTTP/query.base64.layout,MS-ASHTTP,§2.2.1.1.1.1,"Base64 query bytes layout: ProtocolVersion (1B); CommandCode (1B); Locale little-endian (2B); DeviceID len-prefixed; PolicyKey len-prefixed; DeviceType len-prefixed; Params",required From ee52d05b0daa56c67a9e93798589686432240bb1 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:40:38 +0300 Subject: [PATCH 4/7] client: SyncTyped[T] generic wrapper for single-class collections --- README.md | 31 +++++----- client/cmd_sync_typed.go | 27 ++++++++ client/integration_test.go | 67 ++++++++++++++++++++ eas/sync_typed.go | 111 +++++++++++++++++++++++++++++++++ eas/sync_typed_test.go | 122 +++++++++++++++++++++++++++++++++++++ internal/spec/coverage.csv | 1 + 6 files changed, 344 insertions(+), 15 deletions(-) create mode 100644 client/cmd_sync_typed.go create mode 100644 eas/sync_typed.go create mode 100644 eas/sync_typed_test.go diff --git a/README.md b/README.md index b6a4904..aa06606 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ if _, err := c.Provision(ctx, "user@example.com"); err != nil { /* handle */ } ### Sync new e-mails from the inbox ```go -import "github.com/remdev/go-activesync/eas" +import ( + "github.com/remdev/go-activesync/client" + "github.com/remdev/go-activesync/eas" +) initial, _ := c.Sync(ctx, user, &eas.SyncRequest{ Collections: eas.SyncCollections{ @@ -58,7 +61,7 @@ initial, _ := c.Sync(ctx, user, &eas.SyncRequest{ }) syncKey := initial.Collections.Collection[0].SyncKey -resp, _ := c.Sync(ctx, user, &eas.SyncRequest{ +resp, _ := client.SyncTyped[eas.Email](ctx, c, user, &eas.SyncRequest{ Collections: eas.SyncCollections{ Collection: []eas.SyncCollection{{ SyncKey: syncKey, @@ -69,16 +72,20 @@ resp, _ := c.Sync(ctx, user, &eas.SyncRequest{ }, }) -for _, col := range resp.Collections.Collection { - for _, add := range col.Commands.Add { - // add.ServerID identifies the message; add.ApplicationData is an - // opaque carrier in v0.x — see the "Typed Sync payloads" entry in - // the Roadmap below for the planned typed-decoding API. - _ = add.ServerID +for _, col := range resp.Collections { + for _, add := range col.Add { + if add.ApplicationData == nil { + continue + } + log.Printf("new mail %s: %s", add.ServerID, add.ApplicationData.Subject) } } ``` +For mixed-class collections, call `c.Sync` directly and use the +`SyncAdd.Email()` / `Appointment()` / `Contact()` / `Task()` helpers, or +project a single collection with `eas.NewTypedSyncResponse[T]`. + ### Long-poll for changes with Ping ```go @@ -122,6 +129,7 @@ Implemented and covered by the test suite: | Auth | HTTP Basic; pluggable `Authenticator` interface | | Provisioning | Two-pass MS-ASPROV with auto re-provision on Status 142/143 | | Commands | `Provision`, `FolderSync`, `Sync`, `Ping` | +| Typed Sync | `client.SyncTyped[T]`, `eas.UnmarshalApplicationData[T]`, four helpers | | PIM data models | `MS-ASEMAIL`, `MS-ASCAL`, `MS-ASCNTC`, `MS-ASTASK` | | Stores | In-memory `PolicyStore` and `SyncStateStore`; pluggable interfaces | | Hardening | Bounded decoder allocations + `FuzzDecode` over the WBXML reader | @@ -132,13 +140,6 @@ Implemented and covered by the test suite: Out of scope for v0.x; tracked for future releases. -- **Typed `Sync` payloads**: today `SyncCollection.Commands.Add[].ApplicationData` - is `*eas.AppRaw struct{}`, i.e. an opaque carrier — concrete `Email`, - `Appointment`, `Contact` and `Task` bodies are not surfaced by `c.Sync`. - Plan: add a `wbxml.RawElement` primitive so the decoder can preserve the - raw subtree, expose `SyncAdd.Email()/Appointment()/Contact()/Task()` - helpers for mixed-class responses, and provide a generic - `eas.SyncTyped[T]` wrapper for the common single-class case. - **Commands**: `SendMail`, `SmartReply`, `SmartForward`, `MeetingResponse`, `MoveItems`, `ItemOperations` (Fetch/EmptyFolderContents), `GetItemEstimate`, `Search`, `ResolveRecipients`, `ValidateCert`, diff --git a/client/cmd_sync_typed.go b/client/cmd_sync_typed.go new file mode 100644 index 0000000..f6bbf55 --- /dev/null +++ b/client/cmd_sync_typed.go @@ -0,0 +1,27 @@ +package client + +import ( + "context" + + "github.com/remdev/go-activesync/eas" +) + +// SyncTyped is a generic wrapper around Client.Sync that decodes every +// SyncAdd / SyncChange ApplicationData into the requested type T. It is +// intended for the common case of a Sync request that targets a single +// collection of a single Class (Email, Calendar, Contacts, Tasks). +// +// SyncTyped does not surface the raw eas.SyncResponse; callers that need +// access to the wire-level structure (e.g. to introspect Class / Status per +// collection while still picking the right T per collection at runtime) +// should call (*Client).Sync directly and project the response with +// eas.NewTypedSyncResponse[T] themselves. +// +// SPEC: MS-ASCMD/sync.typed +func SyncTyped[T any](ctx context.Context, c *Client, user string, req *eas.SyncRequest) (*eas.TypedSyncResponse[T], error) { + resp, err := c.Sync(ctx, user, req) + if err != nil { + return nil, err + } + return eas.NewTypedSyncResponse[T](resp) +} diff --git a/client/integration_test.go b/client/integration_test.go index 3526cfe..50785c0 100644 --- a/client/integration_test.go +++ b/client/integration_test.go @@ -374,6 +374,73 @@ func mustEmailBody(t *testing.T, e *eas.Email) []byte { return body } +// SPEC: MS-ASCMD/sync.typed +func TestSyncTyped_Email(t *testing.T) { + in := []eas.Email{ + {Subject: "first", From: "a@example.com", To: "b@example.com"}, + {Subject: "second", From: "c@example.com", To: "d@example.com"}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req eas.SyncRequest + decodeWBXML(t, r, &req) + adds := make([]eas.SyncAdd, 0, len(in)) + for i, e := range in { + adds = append(adds, eas.SyncAdd{ + ServerID: serverIDFor(i), + ApplicationData: &wbxml.RawElement{ + Page: wbxml.PageAirSync, + Bytes: mustEmailBody(t, &e), + }, + }) + } + writeWBXML(t, w, &eas.SyncResponse{ + Status: int32(eas.SyncStatusSuccess), + Collections: eas.SyncCollections{ + Collection: []eas.SyncCollection{{ + SyncKey: "S-2", + CollectionID: "1", + Class: "Email", + Status: int32(eas.SyncStatusSuccess), + Commands: &eas.SyncCommands{Add: adds}, + }}, + }, + }) + })) + t.Cleanup(srv.Close) + + c := newTestClient(t, srv) + resp, err := SyncTyped[eas.Email](context.Background(), c, "user@example.com", &eas.SyncRequest{ + Collections: eas.SyncCollections{ + Collection: []eas.SyncCollection{{SyncKey: "0", CollectionID: "1", GetChanges: 1}}, + }, + }) + if err != nil { + t.Fatalf("SyncTyped: %v", err) + } + if len(resp.Collections) != 1 { + t.Fatalf("collections: %d", len(resp.Collections)) + } + col := resp.Collections[0] + if col.Class != "Email" || col.SyncKey != "S-2" { + t.Fatalf("collection metadata: %+v", col) + } + if len(col.Add) != len(in) { + t.Fatalf("Add count = %d, want %d", len(col.Add), len(in)) + } + for i, item := range col.Add { + if item.ApplicationData == nil { + t.Fatalf("Add[%d] ApplicationData nil", i) + } + if item.ApplicationData.Subject != in[i].Subject { + t.Fatalf("Add[%d] Subject %q, want %q", i, item.ApplicationData.Subject, in[i].Subject) + } + } +} + +func serverIDFor(i int) string { + return "1:" + string(rune('0'+i)) +} + // SPEC: MS-ASCMD/ping.response // SPEC: MS-ASCMD/ping.status.changes func TestPing_ChangesAvailable(t *testing.T) { diff --git a/eas/sync_typed.go b/eas/sync_typed.go new file mode 100644 index 0000000..de569d6 --- /dev/null +++ b/eas/sync_typed.go @@ -0,0 +1,111 @@ +package eas + +// TypedSyncResponse mirrors SyncResponse but exposes typed ApplicationData +// values for callers that operate on a single payload class per Sync. +// +// SPEC: MS-ASCMD/sync.typed +type TypedSyncResponse[T any] struct { + Status int32 + Collections []TypedSyncCollection[T] +} + +// TypedSyncCollection mirrors SyncCollection with typed Add/Change items. +// +// SPEC: MS-ASCMD/sync.typed +type TypedSyncCollection[T any] struct { + SyncKey string + CollectionID string + Class string + Status int32 + MoreAvailable int32 + Add []TypedItem[T] + Change []TypedItem[T] + Delete []string // ServerId values from Delete commands +} + +// TypedItem is a SyncAdd or SyncChange whose ApplicationData has been +// decoded into a typed value of T. ApplicationData is nil when the wire +// element was empty or absent. +// +// SPEC: MS-ASCMD/sync.typed +type TypedItem[T any] struct { + ServerID string + ClientID string + ApplicationData *T +} + +// NewTypedSyncResponse projects a SyncResponse into a TypedSyncResponse[T] by +// decoding every SyncAdd/SyncChange ApplicationData via +// UnmarshalApplicationData. Empty ApplicationData fields are preserved as +// nil ApplicationData on the TypedItem; any other decode error fails the +// projection. +// +// SPEC: MS-ASCMD/sync.typed +func NewTypedSyncResponse[T any](resp *SyncResponse) (*TypedSyncResponse[T], error) { + out := &TypedSyncResponse[T]{Status: resp.Status} + for _, col := range resp.Collections.Collection { + tcol := TypedSyncCollection[T]{ + SyncKey: col.SyncKey, + CollectionID: col.CollectionID, + Class: col.Class, + Status: col.Status, + MoreAvailable: col.MoreAvailable, + } + if col.Commands != nil { + adds, err := convertAdds[T](col.Commands.Add) + if err != nil { + return nil, err + } + tcol.Add = adds + changes, err := convertChanges[T](col.Commands.Change) + if err != nil { + return nil, err + } + tcol.Change = changes + tcol.Delete = make([]string, 0, len(col.Commands.Delete)) + for _, d := range col.Commands.Delete { + tcol.Delete = append(tcol.Delete, d.ServerID) + } + } + out.Collections = append(out.Collections, tcol) + } + return out, nil +} + +func convertAdds[T any](src []SyncAdd) ([]TypedItem[T], error) { + if len(src) == 0 { + return nil, nil + } + out := make([]TypedItem[T], 0, len(src)) + for _, a := range src { + item := TypedItem[T]{ServerID: a.ServerID, ClientID: a.ClientID} + if a.ApplicationData != nil && len(a.ApplicationData.Bytes) > 0 { + v, err := UnmarshalApplicationData[T](a.ApplicationData) + if err != nil { + return nil, err + } + item.ApplicationData = v + } + out = append(out, item) + } + return out, nil +} + +func convertChanges[T any](src []SyncChange) ([]TypedItem[T], error) { + if len(src) == 0 { + return nil, nil + } + out := make([]TypedItem[T], 0, len(src)) + for _, c := range src { + item := TypedItem[T]{ServerID: c.ServerID} + if c.ApplicationData != nil && len(c.ApplicationData.Bytes) > 0 { + v, err := UnmarshalApplicationData[T](c.ApplicationData) + if err != nil { + return nil, err + } + item.ApplicationData = v + } + out = append(out, item) + } + return out, nil +} diff --git a/eas/sync_typed_test.go b/eas/sync_typed_test.go new file mode 100644 index 0000000..7642c5e --- /dev/null +++ b/eas/sync_typed_test.go @@ -0,0 +1,122 @@ +package eas + +import ( + "testing" + + "github.com/remdev/go-activesync/wbxml" +) + +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_EmptyAndDeletes(t *testing.T) { + resp := &SyncResponse{ + Status: int32(SyncStatusSuccess), + Collections: SyncCollections{Collection: []SyncCollection{{ + SyncKey: "S-1", + CollectionID: "1", + Class: "Email", + Status: int32(SyncStatusSuccess), + Commands: &SyncCommands{ + Add: []SyncAdd{{ServerID: "1", ApplicationData: nil}}, + Change: []SyncChange{{ServerID: "1"}}, + Delete: []SyncDelete{{ServerID: "del-1"}, {ServerID: "del-2"}}, + }, + }}}, + } + tr, err := NewTypedSyncResponse[Email](resp) + if err != nil { + t.Fatalf("NewTypedSyncResponse: %v", err) + } + if tr.Status != int32(SyncStatusSuccess) { + t.Fatalf("Status: %d", tr.Status) + } + if len(tr.Collections) != 1 { + t.Fatalf("collections: %d", len(tr.Collections)) + } + col := tr.Collections[0] + if len(col.Add) != 1 || col.Add[0].ApplicationData != nil { + t.Fatalf("Add: %+v", col.Add) + } + if len(col.Change) != 1 || col.Change[0].ApplicationData != nil { + t.Fatalf("Change: %+v", col.Change) + } + if len(col.Delete) != 2 || col.Delete[0] != "del-1" || col.Delete[1] != "del-2" { + t.Fatalf("Delete: %+v", col.Delete) + } +} + +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_NilCommands(t *testing.T) { + resp := &SyncResponse{ + Collections: SyncCollections{Collection: []SyncCollection{{SyncKey: "S-1"}}}, + } + tr, err := NewTypedSyncResponse[Email](resp) + if err != nil { + t.Fatalf("NewTypedSyncResponse: %v", err) + } + if len(tr.Collections) != 1 { + t.Fatalf("collections: %d", len(tr.Collections)) + } + if tr.Collections[0].Add != nil || tr.Collections[0].Change != nil || tr.Collections[0].Delete != nil { + t.Fatalf("expected nil command slices for empty Commands") + } +} + +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_AddDecodeError(t *testing.T) { + bad := &wbxml.RawElement{Page: 0xFF, Bytes: []byte{0x01}} + resp := &SyncResponse{ + Collections: SyncCollections{Collection: []SyncCollection{{ + Commands: &SyncCommands{Add: []SyncAdd{{ApplicationData: bad}}}, + }}}, + } + if _, err := NewTypedSyncResponse[Email](resp); err == nil { + t.Fatal("expected decode error for bad Add ApplicationData") + } +} + +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_ChangeDecodeError(t *testing.T) { + bad := &wbxml.RawElement{Page: 0xFF, Bytes: []byte{0x01}} + resp := &SyncResponse{ + Collections: SyncCollections{Collection: []SyncCollection{{ + Commands: &SyncCommands{Change: []SyncChange{{ApplicationData: bad}}}, + }}}, + } + if _, err := NewTypedSyncResponse[Email](resp); err == nil { + t.Fatal("expected decode error for bad Change ApplicationData") + } +} + +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_PopulatedAddChange(t *testing.T) { + body := captureApplicationDataBody(t, &Email{Subject: "ok"}) + resp := &SyncResponse{ + Collections: SyncCollections{Collection: []SyncCollection{{ + Commands: &SyncCommands{ + Add: []SyncAdd{{ + ServerID: "a-1", + ClientID: "c-1", + ApplicationData: &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: body}, + }}, + Change: []SyncChange{{ + ServerID: "a-1", + ApplicationData: &wbxml.RawElement{Page: wbxml.PageAirSync, Bytes: body}, + }}, + }, + }}}, + } + tr, err := NewTypedSyncResponse[Email](resp) + if err != nil { + t.Fatalf("NewTypedSyncResponse: %v", err) + } + col := tr.Collections[0] + if col.Add[0].ApplicationData == nil || col.Add[0].ApplicationData.Subject != "ok" { + t.Fatalf("Add ApplicationData mismatch: %+v", col.Add[0].ApplicationData) + } + if col.Add[0].ClientID != "c-1" { + t.Fatalf("Add ClientID: %q", col.Add[0].ClientID) + } + if col.Change[0].ApplicationData == nil || col.Change[0].ApplicationData.Subject != "ok" { + t.Fatalf("Change ApplicationData mismatch: %+v", col.Change[0].ApplicationData) + } +} diff --git a/internal/spec/coverage.csv b/internal/spec/coverage.csv index 8c44a3f..5d07903 100644 --- a/internal/spec/coverage.csv +++ b/internal/spec/coverage.csv @@ -56,6 +56,7 @@ MS-ASWBXML/marshal.raw,MS-ASWBXML,§2.1.2.1,Marshal honours the ;raw option carr MS-ASWBXML/marshal.raw-page,MS-ASWBXML,§2.1.2.1,RawElement preserves active code-page state for the next sibling element,required MS-ASCMD/sync.applicationdata.raw,MS-ASCMD,§2.2.2.20,Sync ApplicationData is carried as an opaque RawElement in SyncAdd and SyncChange,required MS-ASCMD/sync.applicationdata.typed,MS-ASCMD,§2.2.2.20,Typed convenience helpers decode ApplicationData into Email/Appointment/Contact/Task,required +MS-ASCMD/sync.typed,MS-ASCMD,§2.2.2.20,SyncTyped[T] generic wrapper projects SyncResponse into typed Add/Change/Delete items,required MS-ASWBXML/decoder.fuzz,MS-ASWBXML,§2.1.2.1,Decoder must not panic on any byte input regardless of malformed structure,required MS-ASHTTP/request.path,MS-ASHTTP,§2.2.1,Request line uses POST /Microsoft-Server-ActiveSync,required MS-ASHTTP/query.base64.layout,MS-ASHTTP,§2.2.1.1.1.1,"Base64 query bytes layout: ProtocolVersion (1B); CommandCode (1B); Locale little-endian (2B); DeviceID len-prefixed; PolicyKey len-prefixed; DeviceType len-prefixed; Params",required From 6c1ac3fd939054bf5c94a996378cae9840c80985 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:10:59 +0300 Subject: [PATCH 5/7] address PR #1 review feedback - wbxml/decoder.go: cap CaptureRaw STR_I body length by MaxInlineStringSize to match the existing readNulString limit and stop pathological inputs from exhausting memory. - eas/sync_typed.go: NewTypedSyncResponse returns ErrNilSyncResponse instead of panicking on a nil *SyncResponse. - README.md: add the missing "log" import to the SyncTyped quick example. --- README.md | 2 ++ eas/sync_typed.go | 10 ++++++++++ eas/sync_typed_test.go | 8 ++++++++ wbxml/decoder.go | 5 +++++ wbxml/marshal_extra_test.go | 13 +++++++++++++ 5 files changed, 38 insertions(+) diff --git a/README.md b/README.md index aa06606..f723279 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ if _, err := c.Provision(ctx, "user@example.com"); err != nil { /* handle */ } ```go import ( + "log" + "github.com/remdev/go-activesync/client" "github.com/remdev/go-activesync/eas" ) diff --git a/eas/sync_typed.go b/eas/sync_typed.go index de569d6..b243821 100644 --- a/eas/sync_typed.go +++ b/eas/sync_typed.go @@ -1,5 +1,12 @@ package eas +import "errors" + +// ErrNilSyncResponse is returned by NewTypedSyncResponse / SyncTyped when the +// underlying *SyncResponse is nil. It allows callers to distinguish "the +// transport handed us nothing" from a wbxml decoding error. +var ErrNilSyncResponse = errors.New("eas: nil *SyncResponse") + // TypedSyncResponse mirrors SyncResponse but exposes typed ApplicationData // values for callers that operate on a single payload class per Sync. // @@ -42,6 +49,9 @@ type TypedItem[T any] struct { // // SPEC: MS-ASCMD/sync.typed func NewTypedSyncResponse[T any](resp *SyncResponse) (*TypedSyncResponse[T], error) { + if resp == nil { + return nil, ErrNilSyncResponse + } out := &TypedSyncResponse[T]{Status: resp.Status} for _, col := range resp.Collections.Collection { tcol := TypedSyncCollection[T]{ diff --git a/eas/sync_typed_test.go b/eas/sync_typed_test.go index 7642c5e..5ae4b11 100644 --- a/eas/sync_typed_test.go +++ b/eas/sync_typed_test.go @@ -1,11 +1,19 @@ package eas import ( + "errors" "testing" "github.com/remdev/go-activesync/wbxml" ) +// SPEC: MS-ASCMD/sync.typed +func TestNewTypedSyncResponse_NilInput(t *testing.T) { + if _, err := NewTypedSyncResponse[Email](nil); !errors.Is(err, ErrNilSyncResponse) { + t.Fatalf("nil resp: want ErrNilSyncResponse, got %v", err) + } +} + // SPEC: MS-ASCMD/sync.typed func TestNewTypedSyncResponse_EmptyAndDeletes(t *testing.T) { resp := &SyncResponse{ diff --git a/wbxml/decoder.go b/wbxml/decoder.go index ec3354e..7d42aa6 100644 --- a/wbxml/decoder.go +++ b/wbxml/decoder.go @@ -200,6 +200,7 @@ func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { buf = append(buf, End) case StrI: buf = append(buf, StrI) + strLen := 0 for { c, err := d.r.ReadByte() if err != nil { @@ -212,6 +213,10 @@ func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { if c == 0x00 { break } + strLen++ + if strLen > MaxInlineStringSize { + return nil, fmt.Errorf("wbxml: STR_I exceeds %d-byte limit", MaxInlineStringSize) + } } case StrT: buf = append(buf, StrT) diff --git a/wbxml/marshal_extra_test.go b/wbxml/marshal_extra_test.go index b230d05..b8896c0 100644 --- a/wbxml/marshal_extra_test.go +++ b/wbxml/marshal_extra_test.go @@ -870,6 +870,19 @@ func TestDecoder_CaptureRaw_TruncatedStrI(t *testing.T) { } } +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_StrIExceedsLimit(t *testing.T) { + prev := MaxInlineStringSize + MaxInlineStringSize = 4 + defer func() { MaxInlineStringSize = prev }() + stream := append([]byte{StrI}, []byte("aaaaaaaa")...) + stream = append(stream, 0x00, End) + d := NewDecoder(bytes.NewReader(stream)) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected STR_I limit error") + } +} + // SPEC: MS-ASWBXML/marshal.raw func TestDecoder_CaptureRaw_StrTAndOpaque(t *testing.T) { // StrT 0x83 + offset 5 (mb_u_int32 single byte), then END for outer. From e9ad9ba5a04f96114b0ab3dc9e52fe03aa669737 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:36:20 +0300 Subject: [PATCH 6/7] address PR #1 second review pass - wbxml/decoder.go: introduce MaxRawElementSize and enforce it inside CaptureRaw so a flood of small tags or tokens cannot grow the capture buffer past the cap, complementing the existing per-token limits. - wbxml/marshal.go: encodeRawField copies a RawElement-by-value field via reflect.Value.Interface() instead of v.Addr() so Marshal does not panic when the parent struct is passed by value (unaddressable). --- wbxml/decoder.go | 16 ++++++++++++++++ wbxml/marshal.go | 6 +++++- wbxml/marshal_extra_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/wbxml/decoder.go b/wbxml/decoder.go index 7d42aa6..2796c92 100644 --- a/wbxml/decoder.go +++ b/wbxml/decoder.go @@ -19,6 +19,13 @@ var MaxOpaqueSize uint32 = 64 << 20 // 64 MiB // string. The same rationale as MaxOpaqueSize applies. var MaxInlineStringSize = 16 << 20 // 16 MiB +// MaxRawElementSize bounds the cumulative size of bytes captured by a single +// Decoder.CaptureRaw call. Per-token caps (MaxOpaqueSize, MaxInlineStringSize) +// already neutralise individual giant payloads; this cap additionally limits +// the total buffer growth from a flood of small tokens or tags within one +// element body. Exposed as a var so callers (and tests) may tune it. +var MaxRawElementSize = 64 << 20 // 64 MiB + // TokenKind classifies a logical WBXML token after SWITCH_PAGE handling. type TokenKind int @@ -171,6 +178,12 @@ func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { return nil, nil } var buf []byte + checkBudget := func() error { + if len(buf) > MaxRawElementSize { + return fmt.Errorf("wbxml: raw element exceeds %d-byte limit", MaxRawElementSize) + } + return nil + } depth := 1 for depth > 0 { b, err := d.r.ReadByte() @@ -250,6 +263,9 @@ func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { depth++ } } + if err := checkBudget(); err != nil { + return nil, err + } } return buf, nil } diff --git a/wbxml/marshal.go b/wbxml/marshal.go index 056f3f9..e060733 100644 --- a/wbxml/marshal.go +++ b/wbxml/marshal.go @@ -587,7 +587,11 @@ func encodeRawField(enc *Encoder, v reflect.Value, fs *fieldSpec) error { if v.Type() != rawElementType { return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement, got %s", fs.pageName, fs.tagName, v.Type()) } - raw = v.Addr().Interface().(*RawElement) + // v may be unaddressable (e.g. when the caller passed a value, not a + // pointer, to Marshal); copy the RawElement out by value so we never + // rely on v.Addr() succeeding. + rawValue := v.Interface().(RawElement) + raw = &rawValue default: return fmt.Errorf("wbxml: ,raw field %s.%s must be RawElement or *RawElement, got %s", fs.pageName, fs.tagName, v.Type()) } diff --git a/wbxml/marshal_extra_test.go b/wbxml/marshal_extra_test.go index b8896c0..f21fcb5 100644 --- a/wbxml/marshal_extra_test.go +++ b/wbxml/marshal_extra_test.go @@ -735,6 +735,26 @@ func TestMarshal_RawElement_ByValue(t *testing.T) { } } +// SPEC: MS-ASWBXML/marshal.raw +// TestMarshal_RawElement_ByValue_NonAddressable verifies that a RawElement +// value field marshals without panicking when the parent struct itself is +// passed by value (i.e. unaddressable inside reflect). +func TestMarshal_RawElement_ByValue_NonAddressable(t *testing.T) { + body := emailBodyBytes(t, "v") + in := rawByValue{AppData: RawElement{Page: PageAirSync, Bytes: body}} + data, err := Marshal(in) + if err != nil { + t.Fatalf("Marshal(value): %v", err) + } + var out rawByValue + if err := Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if !bytes.Equal(out.AppData.Bytes, body) { + t.Fatalf("body mismatch") + } +} + type rawWrongPtrType struct { XMLName struct{} `wbxml:"AirSync.Sync"` AppData *rawByValue `wbxml:"AirSync.ApplicationData,raw"` @@ -870,6 +890,20 @@ func TestDecoder_CaptureRaw_TruncatedStrI(t *testing.T) { } } +// SPEC: MS-ASWBXML/marshal.raw +func TestDecoder_CaptureRaw_RawElementExceedsLimit(t *testing.T) { + prev := MaxRawElementSize + MaxRawElementSize = 4 + defer func() { MaxRawElementSize = prev }() + // A flood of small tag bytes (each one statement-sized in buf) under the + // individual STR_I/OPAQUE caps but past the overall budget. + stream := []byte{0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, End} + d := NewDecoder(bytes.NewReader(stream)) + if _, err := d.CaptureRaw(true); err == nil { + t.Fatal("expected raw-element limit error") + } +} + // SPEC: MS-ASWBXML/marshal.raw func TestDecoder_CaptureRaw_StrIExceedsLimit(t *testing.T) { prev := MaxInlineStringSize From 441b96ea13bbae802f34b4a5ccbf527ed744a5b8 Mon Sep 17 00:00:00 2001 From: Misha <6481198+remdev@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:05:36 +0300 Subject: [PATCH 7/7] wbxml: fix off-by-one in CaptureRaw STR_I cap The previous check incremented strLen and tested `>` after appending the byte, allowing one extra byte past MaxInlineStringSize before failing. Mirror readNulString instead: check the limit before append using `>=`, matching the rest of the decoder limits exactly. --- wbxml/decoder.go | 7 ++++--- wbxml/marshal_extra_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/wbxml/decoder.go b/wbxml/decoder.go index 2796c92..fce9d35 100644 --- a/wbxml/decoder.go +++ b/wbxml/decoder.go @@ -222,14 +222,15 @@ func (d *Decoder) CaptureRaw(hasContent bool) ([]byte, error) { } return nil, err } - buf = append(buf, c) if c == 0x00 { + buf = append(buf, c) break } - strLen++ - if strLen > MaxInlineStringSize { + if strLen >= MaxInlineStringSize { return nil, fmt.Errorf("wbxml: STR_I exceeds %d-byte limit", MaxInlineStringSize) } + buf = append(buf, c) + strLen++ } case StrT: buf = append(buf, StrT) diff --git a/wbxml/marshal_extra_test.go b/wbxml/marshal_extra_test.go index f21fcb5..a18e47a 100644 --- a/wbxml/marshal_extra_test.go +++ b/wbxml/marshal_extra_test.go @@ -917,6 +917,30 @@ func TestDecoder_CaptureRaw_StrIExceedsLimit(t *testing.T) { } } +// SPEC: MS-ASWBXML/marshal.raw +// TestDecoder_CaptureRaw_StrIBoundary verifies the STR_I cap is exact: +// MaxInlineStringSize bytes are accepted, MaxInlineStringSize+1 bytes are +// rejected before the extra byte is appended (no off-by-one). +func TestDecoder_CaptureRaw_StrIBoundary(t *testing.T) { + prev := MaxInlineStringSize + MaxInlineStringSize = 4 + defer func() { MaxInlineStringSize = prev }() + + atLimit := append([]byte{StrI}, []byte("aaaa")...) + atLimit = append(atLimit, 0x00, End) + d := NewDecoder(bytes.NewReader(atLimit)) + if _, err := d.CaptureRaw(true); err != nil { + t.Fatalf("at-limit STR_I should pass, got %v", err) + } + + overLimit := append([]byte{StrI}, []byte("aaaaa")...) + overLimit = append(overLimit, 0x00, End) + d2 := NewDecoder(bytes.NewReader(overLimit)) + if _, err := d2.CaptureRaw(true); err == nil { + t.Fatal("over-limit STR_I should fail") + } +} + // SPEC: MS-ASWBXML/marshal.raw func TestDecoder_CaptureRaw_StrTAndOpaque(t *testing.T) { // StrT 0x83 + offset 5 (mb_u_int32 single byte), then END for outer.