diff --git a/internal/xmlrpc/xmlrpc.go b/internal/xmlrpc/xmlrpc.go
new file mode 100644
index 0000000..0fcd820
--- /dev/null
+++ b/internal/xmlrpc/xmlrpc.go
@@ -0,0 +1,525 @@
+// Package xmlrpc parses and writes XML-RPC calls and responses, keeping
+// value types and struct member order exactly.
+package xmlrpc
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+)
+
+// MaxSize is the largest document accepted.
+const MaxSize = 64 << 20
+
+// MaxDepth is the deepest nesting of arrays and structs accepted.
+const MaxDepth = 64
+
+// Value is one of: int64, bool, string, float64, []byte (base64), DateTime,
+// []Value (array), Struct, or nil ().
+type Value = any
+
+// DateTime is dateTime.iso8601 text, kept as is.
+type DateTime string
+
+// Member is a struct member.
+type Member struct {
+ Name string
+ Value Value
+}
+
+// Struct is an XML-RPC struct, in document order.
+type Struct []Member
+
+// Get returns the value of the first member with the name.
+func (s Struct) Get(name string) (Value, bool) {
+ for _, m := range s {
+ if m.Name == name {
+ return m.Value, true
+ }
+ }
+ return nil, false
+}
+
+// Call is a methodCall.
+type Call struct {
+ Method string
+ Params []Value
+}
+
+// Response is a methodResponse: Params, or Fault if it's set.
+type Response struct {
+ Params []Value
+ Fault *Fault
+}
+
+// Fault is a fault response's code and message.
+type Fault struct {
+ Code int64
+ String string
+}
+
+func (f *Fault) Error() string { return fmt.Sprintf("xmlrpc fault %d: %s", f.Code, f.String) }
+
+type parser struct{ d *xml.Decoder }
+
+func newParser(data []byte) (*parser, error) {
+ if len(data) > MaxSize {
+ return nil, errors.New("xmlrpc: document too large")
+ }
+ d := xml.NewDecoder(bytes.NewReader(data))
+ d.Strict = true
+ d.CharsetReader = func(label string, r io.Reader) (io.Reader, error) {
+ switch strings.ToLower(label) {
+ case "utf-8", "utf8", "us-ascii", "ascii":
+ return r, nil
+ }
+ return nil, fmt.Errorf("xmlrpc: unsupported encoding %q", label)
+ }
+ return &parser{d}, nil
+}
+
+func (p *parser) token() (xml.Token, error) {
+ t, err := p.d.Token()
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ return t, err
+}
+
+// next returns the next start or end element, skipping whitespace,
+// comments and processing instructions.
+func (p *parser) next() (xml.Token, error) {
+ for {
+ t, err := p.token()
+ if err != nil {
+ return nil, err
+ }
+ switch t := t.(type) {
+ case xml.StartElement, xml.EndElement:
+ return t, nil
+ case xml.CharData:
+ if len(bytes.TrimSpace(t)) != 0 {
+ return nil, fmt.Errorf("xmlrpc: unexpected text %q", string(t))
+ }
+ }
+ }
+}
+
+func (p *parser) start(name string) error {
+ t, err := p.next()
+ if err != nil {
+ return err
+ }
+ if s, ok := t.(xml.StartElement); !ok || s.Name.Local != name {
+ return fmt.Errorf("xmlrpc: expected <%s>", name)
+ }
+ return nil
+}
+
+func (p *parser) end(name string) error {
+ t, err := p.next()
+ if err != nil {
+ return err
+ }
+ if e, ok := t.(xml.EndElement); !ok || e.Name.Local != name {
+ return fmt.Errorf("xmlrpc: expected %s>", name)
+ }
+ return nil
+}
+
+// text reads character data up to the current element's end.
+func (p *parser) text() (string, error) {
+ var b strings.Builder
+ for {
+ t, err := p.token()
+ if err != nil {
+ return "", err
+ }
+ switch t := t.(type) {
+ case xml.CharData:
+ b.Write(t)
+ case xml.EndElement:
+ return b.String(), nil
+ case xml.StartElement:
+ return "", fmt.Errorf("xmlrpc: unexpected <%s>", t.Name.Local)
+ }
+ }
+}
+
+// eof checks that only whitespace follows the root element.
+func (p *parser) eof() error {
+ for {
+ t, err := p.d.Token()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ switch t := t.(type) {
+ case xml.StartElement, xml.EndElement:
+ return errors.New("xmlrpc: content after document")
+ case xml.CharData:
+ if len(bytes.TrimSpace(t)) != 0 {
+ return errors.New("xmlrpc: content after document")
+ }
+ }
+ }
+}
+
+// value parses a value's content; its start was read.
+// Bare text is a string, whitespace included.
+func (p *parser) value(depth int) (Value, error) {
+ var text strings.Builder
+ for {
+ t, err := p.token()
+ if err != nil {
+ return nil, err
+ }
+ switch t := t.(type) {
+ case xml.CharData:
+ text.Write(t)
+ case xml.EndElement:
+ return text.String(), nil
+ case xml.StartElement:
+ if strings.TrimSpace(text.String()) != "" {
+ return nil, errors.New("xmlrpc: text mixed with elements in ")
+ }
+ v, err := p.typed(t.Name.Local, depth)
+ if err != nil {
+ return nil, err
+ }
+ return v, p.end("value")
+ }
+ }
+}
+
+func (p *parser) typed(name string, depth int) (Value, error) {
+ switch name {
+ case "array", "struct":
+ if depth >= MaxDepth {
+ return nil, errors.New("xmlrpc: nested too deeply")
+ }
+ if name == "array" {
+ return p.array(depth + 1)
+ }
+ return p.strct(depth + 1)
+ }
+ s, err := p.text()
+ if err != nil {
+ return nil, err
+ }
+ switch name {
+ case "nil":
+ return nil, nil
+ case "string":
+ return s, nil
+ case "int", "i4", "i8":
+ n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("xmlrpc: bad <%s> %q", name, s)
+ }
+ return n, nil
+ case "boolean":
+ switch strings.TrimSpace(s) {
+ case "0":
+ return false, nil
+ case "1":
+ return true, nil
+ }
+ return nil, fmt.Errorf("xmlrpc: bad %q", s)
+ case "double":
+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
+ if err != nil {
+ return nil, fmt.Errorf("xmlrpc: bad %q", s)
+ }
+ return f, nil
+ case "dateTime.iso8601":
+ return DateTime(s), nil
+ case "base64":
+ b, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(s), ""))
+ if err != nil {
+ return nil, fmt.Errorf("xmlrpc: bad : %v", err)
+ }
+ return b, nil
+ }
+ return nil, fmt.Errorf("xmlrpc: unknown type <%s>", name)
+}
+
+func (p *parser) array(depth int) (Value, error) {
+ if err := p.start("data"); err != nil {
+ return nil, err
+ }
+ a := []Value{}
+ for {
+ t, err := p.next()
+ if err != nil {
+ return nil, err
+ }
+ switch t := t.(type) {
+ case xml.EndElement:
+ return a, p.end("array")
+ case xml.StartElement:
+ if t.Name.Local != "value" {
+ return nil, fmt.Errorf("xmlrpc: unexpected <%s> in ", t.Name.Local)
+ }
+ v, err := p.value(depth)
+ if err != nil {
+ return nil, err
+ }
+ a = append(a, v)
+ }
+ }
+}
+
+func (p *parser) strct(depth int) (Value, error) {
+ s := Struct{}
+ for {
+ t, err := p.next()
+ if err != nil {
+ return nil, err
+ }
+ switch t := t.(type) {
+ case xml.EndElement:
+ return s, nil
+ case xml.StartElement:
+ if t.Name.Local != "member" {
+ return nil, fmt.Errorf("xmlrpc: unexpected <%s> in ", t.Name.Local)
+ }
+ if err := p.start("name"); err != nil {
+ return nil, err
+ }
+ name, err := p.text()
+ if err != nil {
+ return nil, err
+ }
+ if err := p.start("value"); err != nil {
+ return nil, err
+ }
+ v, err := p.value(depth)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.end("member"); err != nil {
+ return nil, err
+ }
+ s = append(s, Member{name, v})
+ }
+ }
+}
+
+// params parses elements up to .
+func (p *parser) params() ([]Value, error) {
+ ps := []Value{}
+ for {
+ t, err := p.next()
+ if err != nil {
+ return nil, err
+ }
+ switch t := t.(type) {
+ case xml.EndElement:
+ return ps, nil
+ case xml.StartElement:
+ if t.Name.Local != "param" {
+ return nil, fmt.Errorf("xmlrpc: unexpected <%s> in ", t.Name.Local)
+ }
+ if err := p.start("value"); err != nil {
+ return nil, err
+ }
+ v, err := p.value(0)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.end("param"); err != nil {
+ return nil, err
+ }
+ ps = append(ps, v)
+ }
+ }
+}
+
+// ParseCall parses a methodCall document.
+func ParseCall(data []byte) (*Call, error) {
+ p, err := newParser(data)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.start("methodCall"); err != nil {
+ return nil, err
+ }
+ if err := p.start("methodName"); err != nil {
+ return nil, err
+ }
+ method, err := p.text()
+ if err != nil {
+ return nil, err
+ }
+ c := &Call{Method: strings.TrimSpace(method), Params: []Value{}}
+ t, err := p.next()
+ if err != nil {
+ return nil, err
+ }
+ if s, ok := t.(xml.StartElement); ok && s.Name.Local == "params" {
+ if c.Params, err = p.params(); err != nil {
+ return nil, err
+ }
+ if err := p.end("methodCall"); err != nil {
+ return nil, err
+ }
+ } else if e, ok := t.(xml.EndElement); !ok || e.Name.Local != "methodCall" {
+ return nil, errors.New("xmlrpc: expected ")
+ }
+ return c, p.eof()
+}
+
+// ParseResponse parses a methodResponse document.
+func ParseResponse(data []byte) (*Response, error) {
+ p, err := newParser(data)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.start("methodResponse"); err != nil {
+ return nil, err
+ }
+ t, err := p.next()
+ if err != nil {
+ return nil, err
+ }
+ s, _ := t.(xml.StartElement)
+ r := &Response{}
+ switch s.Name.Local {
+ case "params":
+ if r.Params, err = p.params(); err != nil {
+ return nil, err
+ }
+ case "fault":
+ if r.Fault, err = p.fault(); err != nil {
+ return nil, err
+ }
+ default:
+ return nil, errors.New("xmlrpc: expected or ")
+ }
+ if err := p.end("methodResponse"); err != nil {
+ return nil, err
+ }
+ return r, p.eof()
+}
+
+func (p *parser) fault() (*Fault, error) {
+ if err := p.start("value"); err != nil {
+ return nil, err
+ }
+ v, err := p.value(0)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.end("fault"); err != nil {
+ return nil, err
+ }
+ st, _ := v.(Struct)
+ code, _ := st.Get("faultCode")
+ msg, _ := st.Get("faultString")
+ f := &Fault{}
+ var ok1, ok2 bool
+ f.Code, ok1 = code.(int64)
+ f.String, ok2 = msg.(string)
+ if !ok1 || !ok2 {
+ return nil, errors.New("xmlrpc: bad fault")
+ }
+ return f, nil
+}
+
+// Marshal writes the call. It panics on a value of an unsupported type.
+func (c *Call) Marshal() []byte {
+ var b bytes.Buffer
+ b.WriteString("\n\n")
+ xml.EscapeText(&b, []byte(c.Method))
+ b.WriteString("\n")
+ writeParams(&b, c.Params)
+ b.WriteString("\n")
+ return b.Bytes()
+}
+
+// Marshal writes the response. It panics on a value of an unsupported type.
+func (r *Response) Marshal() []byte {
+ var b bytes.Buffer
+ b.WriteString("\n\n")
+ if r.Fault != nil {
+ b.WriteString("\n")
+ writeValue(&b, Struct{{"faultCode", r.Fault.Code}, {"faultString", r.Fault.String}})
+ b.WriteString("\n")
+ } else {
+ writeParams(&b, r.Params)
+ }
+ b.WriteString("\n")
+ return b.Bytes()
+}
+
+func writeParams(b *bytes.Buffer, ps []Value) {
+ b.WriteString("\n")
+ for _, v := range ps {
+ b.WriteString("\n")
+ writeValue(b, v)
+ b.WriteString("\n")
+ }
+ b.WriteString("\n")
+}
+
+// writeText escapes s; xml.EscapeText also escapes quotes and \t\n\r as
+// character references, so whitespace survives exactly.
+func writeText(b *bytes.Buffer, s string) { xml.EscapeText(b, []byte(s)) }
+
+func writeValue(b *bytes.Buffer, v Value) {
+ b.WriteString("")
+ switch v := v.(type) {
+ case nil:
+ b.WriteString("")
+ case int64:
+ fmt.Fprintf(b, "%d", v)
+ case int:
+ fmt.Fprintf(b, "%d", v)
+ case bool:
+ if v {
+ b.WriteString("1")
+ } else {
+ b.WriteString("0")
+ }
+ case string:
+ b.WriteString("")
+ writeText(b, v)
+ b.WriteString("")
+ case float64:
+ // Shortest round-trip form; Python's float() parses it, NaN and Inf too.
+ fmt.Fprintf(b, "%s", strconv.FormatFloat(v, 'g', -1, 64))
+ case []byte:
+ fmt.Fprintf(b, "%s", base64.StdEncoding.EncodeToString(v))
+ case DateTime:
+ b.WriteString("")
+ writeText(b, string(v))
+ b.WriteString("")
+ case []Value:
+ b.WriteString("\n")
+ for _, e := range v {
+ writeValue(b, e)
+ }
+ b.WriteString("")
+ case Struct:
+ b.WriteString("\n")
+ for _, m := range v {
+ b.WriteString("\n")
+ writeText(b, m.Name)
+ b.WriteString("\n")
+ writeValue(b, m.Value)
+ b.WriteString("\n")
+ }
+ b.WriteString("")
+ default:
+ panic(fmt.Sprintf("xmlrpc: unsupported type %T", v))
+ }
+ b.WriteString("\n")
+}
diff --git a/internal/xmlrpc/xmlrpc_test.go b/internal/xmlrpc/xmlrpc_test.go
new file mode 100644
index 0000000..0ebbd83
--- /dev/null
+++ b/internal/xmlrpc/xmlrpc_test.go
@@ -0,0 +1,303 @@
+package xmlrpc
+
+import (
+ "bytes"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// Python 2.7 xmlrpclib.dumps(..., methodname=...) output, synthetic values.
+const authorizeCall = `
+
+test.authorizeProducts2
+
+
+
+
+auths
+
+
+userId
+productId
+
+
+123
+456
+
+
+
+
+use_extracted_assets_if_available
+1
+
+
+max_supported_format
+9
+
+
+
+
+
+`
+
+// PHP-style response: declaration with encoding, indentation, i4, bare text.
+const phpResponse = `
+
+
+
+
+
+
+
+
+
+ 1001
+ Spaced & <name>
+ 0
+
+
+
+
+
+
+ -7
+ bare text
+ 1
+ 9007199254740993
+ 0.1
+ 20261029T12:34:56
+ aGVs
+bG8=
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+const faultResponse = `
+
+
+
+
+ faultCode4
+ faultStringToo many parameters.
+
+
+
+
+`
+
+const unicodeCall = "\ntest.echo" +
+ "café 日本 \U0001F600 ☺" +
+ ""
+
+func TestParseCall(t *testing.T) {
+ c, err := ParseCall([]byte(authorizeCall))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := &Call{Method: "test.authorizeProducts2", Params: []Value{Struct{
+ {"auths", []Value{[]Value{"userId", "productId"}, []Value{int64(123), int64(456)}}},
+ {"use_extracted_assets_if_available", true},
+ {"max_supported_format", int64(9)},
+ }}}
+ if !reflect.DeepEqual(c, want) {
+ t.Fatalf("got %#v", c)
+ }
+ roundTripCall(t, c)
+}
+
+func TestParseResponse(t *testing.T) {
+ r, err := ParseResponse([]byte(phpResponse))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := &Response{Params: []Value{[]Value{
+ []Value{int64(1001), " Spaced & ", false},
+ []Value{int64(-7), "bare text", true, int64(9007199254740993), 0.1,
+ DateTime("20261029T12:34:56"), []byte("hello"), nil, "", "", "", Struct{}, []Value{}},
+ }}}
+ if !reflect.DeepEqual(r, want) {
+ t.Fatalf("got %#v", r)
+ }
+ roundTripResponse(t, r)
+}
+
+func TestFault(t *testing.T) {
+ r, err := ParseResponse([]byte(faultResponse))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Fault == nil || *r.Fault != (Fault{4, "Too many parameters."}) || r.Params != nil {
+ t.Fatalf("got %#v", r)
+ }
+ out := r.Marshal()
+ if !bytes.Contains(out, []byte("\n")) {
+ t.Fatalf("marshal: %s", out)
+ }
+ roundTripResponse(t, r)
+}
+
+func TestUnicode(t *testing.T) {
+ c, err := ParseCall([]byte(unicodeCall))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s := c.Params[0]; s != "café 日本 \U0001F600 ☺" {
+ t.Fatalf("got %q", s)
+ }
+ roundTripCall(t, c)
+}
+
+func TestWhitespaceInString(t *testing.T) {
+ r := &Response{Params: []Value{" a\tb\r\nc ", Struct{{"k\n", "\r"}}, 1.5e300, -0.0}}
+ roundTripResponse(t, r)
+}
+
+func TestMarshalFormat(t *testing.T) {
+ c := &Call{Method: "m", Params: []Value{int64(1), true, "a<&>", nil, 2.5}}
+ want := "\n\nm\n\n" +
+ "\n1\n\n" +
+ "\n1\n\n" +
+ "\na<&>\n\n" +
+ "\n\n\n" +
+ "\n2.5\n\n" +
+ "\n\n"
+ if got := string(c.Marshal()); got != want {
+ t.Fatalf("got\n%s", got)
+ }
+}
+
+func TestCallWithoutParams(t *testing.T) {
+ c, err := ParseCall([]byte("x"))
+ if err != nil || c.Method != "x" || len(c.Params) != 0 {
+ t.Fatal(c, err)
+ }
+}
+
+func TestErrors(t *testing.T) {
+ bad := []string{
+ "",
+ "",
+ "x",
+ "2",
+ "z",
+ "!!",
+ "",
+ "x1",
+ "1",
+ "1",
+ "a",
+ "",
+ "xjunk",
+ `x`,
+ }
+ for _, s := range bad {
+ _, err1 := ParseResponse([]byte(s))
+ _, err2 := ParseCall([]byte(s))
+ if err1 == nil && err2 == nil {
+ t.Errorf("accepted %q", s)
+ }
+ }
+}
+
+func nested(n int) string {
+ return "" +
+ strings.Repeat("", n) + "1" +
+ strings.Repeat("", n) +
+ ""
+}
+
+func TestDepth(t *testing.T) {
+ if _, err := ParseResponse([]byte(nested(MaxDepth))); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ParseResponse([]byte(nested(MaxDepth + 1))); err == nil {
+ t.Fatal("accepted too deep")
+ }
+}
+
+func TestSize(t *testing.T) {
+ big := make([]byte, MaxSize+1)
+ if _, err := ParseCall(big); err == nil {
+ t.Fatal("accepted too large")
+ }
+}
+
+func roundTripCall(t *testing.T, c *Call) {
+ t.Helper()
+ c2, err := ParseCall(c.Marshal())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(c, c2) {
+ t.Fatalf("round trip:\n%#v\n%#v", c, c2)
+ }
+}
+
+func roundTripResponse(t *testing.T, r *Response) {
+ t.Helper()
+ r2, err := ParseResponse(r.Marshal())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Params == nil && r.Fault == nil {
+ r.Params = []Value{}
+ }
+ if !reflect.DeepEqual(r, r2) {
+ t.Fatalf("round trip:\n%#v\n%#v", r, r2)
+ }
+}
+
+// Fuzzed documents must not panic, and whatever parses must re-marshal stably
+// (bytes, not values, so NaN compares equal).
+func FuzzParseCall(f *testing.F) {
+ f.Add([]byte(authorizeCall))
+ f.Add([]byte(unicodeCall))
+ f.Fuzz(func(t *testing.T, data []byte) {
+ c, err := ParseCall(data)
+ if err != nil {
+ return
+ }
+ out := c.Marshal()
+ c2, err := ParseCall(out)
+ if err != nil {
+ t.Fatalf("reparse: %v\n%s", err, out)
+ }
+ if !bytes.Equal(out, c2.Marshal()) {
+ t.Fatalf("unstable:\n%s", out)
+ }
+ })
+}
+
+func FuzzParseResponse(f *testing.F) {
+ f.Add([]byte(phpResponse))
+ f.Add([]byte(faultResponse))
+ f.Add([]byte(nested(3)))
+ f.Fuzz(func(t *testing.T, data []byte) {
+ r, err := ParseResponse(data)
+ if err != nil {
+ return
+ }
+ out := r.Marshal()
+ r2, err := ParseResponse(out)
+ if err != nil {
+ t.Fatalf("reparse: %v\n%s", err, out)
+ }
+ if !bytes.Equal(out, r2.Marshal()) {
+ t.Fatalf("unstable:\n%s", out)
+ }
+ })
+}