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 ", 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) + } + }) +}