document rewrite
authornsz <nsz@port70.net>
Tue, 22 Nov 2011 22:59:10 +0000 (23:59 +0100)
committernsz <nsz@port70.net>
Tue, 22 Nov 2011 22:59:10 +0000 (23:59 +0100)
document/document.go
document/document_test.go

index 1c61fcb..c410f61 100644 (file)
@@ -1,26 +1,42 @@
-package document
-
-// An epoint document is a clear signed utf-8 text of key-value pairs
-// according to OpenPGP RFC 4880. The body contains a content-type
-// MIME header so it can be used in OpenPGP/MIME RFC 3156 emails with
-// the signature detached. The format of the key-value pairs are
-// similar to MIME header fields.
+// Package document implements epoint document parsing and creation.
+//
+// An epoint document is an OpenPGP (RFC 4880) clear signed
+// utf-8 text of key-value pairs.
+// The body contains a content-type MIME header so the document
+// can be used in OpenPGP/MIME (RFC 3156) emails.
+// The format of the key-value pairs are similar to MIME header
+// fields: keys and values are separated by ": ", repeated keys
+// are not allowed, long values can be split before a space.
 //
 // Example:
 //
 // -----BEGIN PGP SIGNED MESSAGE-----
 // Hash: SHA1
 //
-// Content-Type: text/plain.epoint.cert; charset=utf-8
+// Content-Type: text/plain.epoint.type; charset=utf-8
 //
 // Key: Value1
 // Another-Key: Value2
+// Last-Key: Long
+//  value that spans
+//  multiple lines
 // -----BEGIN PGP SIGNATURE-----
+//
 // pgp signature
 // -----END PGP SIGNATURE-----
+package document
+
+// TODO: error types
+// TODO: optional fields
+// TODO: fields of notice (last notice, serial)
+// TODO: space vs not to space
 
 import (
        "bytes"
+       "crypto"
+       "crypto/openpgp"
+       "crypto/openpgp/armor"
+       "crypto/openpgp/packet"
        "encoding/hex"
        "fmt"
        "reflect"
@@ -29,464 +45,567 @@ import (
        "time"
 )
 
-const ClearSignedHeader = "-----BEGIN PGP SIGNED MESSAGE-----\n"
+const ClearSignedHeader = "-----BEGIN PGP SIGNED MESSAGE-----"
 
-// (non-standard) MIME type for epoint documents, see RFC 2045 and RFC 2046
+// MIME type for epoint documents, see RFC 4288
 var ContentType = map[string]string{
-       "cert":  "text/plain.epoint.cert; charset=utf-8",
-       "draft": "text/plain.epoint.draft; charset=utf-8",
+       "Draft":      "text/vnd.epoint.draft; charset=utf-8",
+       "Notice":     "text/vnd.epoint.notice; charset=utf-8",
+       "DebitCert":  "text/vnd.epoint.debit; charset=utf-8",
+       "CreditCert": "text/vnd.epoint.credit; charset=utf-8",
+       "BounceCert": "text/vnd.epoint.bounce; charset=utf-8",
 }
 
 // OpenPGP signed cleartext document representation
-type ClearSigned struct {
+type Signed struct {
+       // Sign and CleanSigned sets Hash for FormatSigned
+       // TODO: CreationDate
        Hash string
-       // Signed text (no dash escape, no trailing space)
+       // Signed text (no dash escape, no trailing space, \n new lines)
        Body []byte
        // Armored detached text signature of the Body
-       ArmoredSignature []byte
+       Signature []byte
+}
+
+// parsed epoint document
+type Document struct {
+       Type   string
+       Fields map[string]string
+       Order  []string
 }
 
-type Field struct {
-       Key   string
-       Value string
+var fieldtype = map[string]string{
+       "Amount":             "int",
+       "Authorized-By":      "id",
+       "Balance":            "int",
+       "Beneficiary":        "id",
+       "Date":               "date",
+       "Debit-Cert":         "id",
+       "Denomination":       "text",
+       "Difference":         "int",
+       "Draft":              "id",
+       "Drawer":             "id",
+       "Expiry-Date":        "date",
+       "Holder":             "id",
+       "Issuer":             "id",
+       "Last-Cert":          "id",
+       "Last-Credit-Serial": "int",
+       "Last-Debit-Serial":  "int",
+       "Maturity-Date":      "date",
+       "Nonce":              "text",
+       "Notes":              "text",
+       "References":         "ids",
+       "Serial":             "int",
+}
+
+var fieldname = map[string]string{
+       "AuthorizedBy":     "Authorized-By",
+       "DebitCert":        "Debit-Cert",
+       "ExpiryDate":       "Expiry-Date",
+       "LastCert":         "Last-Cert",
+       "LastCreditSerial": "Last-Credit-Serial",
+       "LastDebitSerial":  "Last-Debit-Serial",
+       "MaturityDate":     "Maturity-Date",
 }
 
-// Draft document represents an obligation transfer order
 type Draft struct {
-       Drawer       string `marshal:"id"`
-       Beneficiary  string `marshal:"id"`
+       Drawer       string
+       Beneficiary  string
        Amount       int64
        Denomination string
-       Issuer       string `marshal:"id"`
-       AuthorizedBy string `marshal:"id" key:"Authorized-By"`
-       Date         int64  `marshal:"date"`
-       MaturityDate int64  `marshal:"date" key:"Maturity-Date"`
-       ExpiryDate   int64  `marshal:"date" key:"Expiry-Date"`
+       Issuer       string
+       AuthorizedBy string
+       MaturityDate int64  // optional
+       ExpiryDate   int64  // optional
+       Nonce        string // optional
+       Notes        string // optional
+}
+
+type Notice struct {
+       Date         int64
+       AuthorizedBy string
        Notes        string
-       Nonce        string
-       // useful if more strict date of issue information is needed
-       //References []string
+       References   []string
 }
 
-// Obligation certificate after a transfer
-// References previous certificate (if any)
-// and the transfer related other documents
-type Cert struct {
-       Holder           string `marshal:"id"`
+type DebitCert struct {
+       Holder           string
        Serial           int64
-       Date             int64 `marshal:"date"`
        Balance          int64
        Denomination     string
-       Issuer           string `marshal:"id"`
-       AuthorizedBy     string `marshal:"id" key:"Authorized-By"`
-       LastDebitSerial  int64  `key:"Last-Debit-Serial"`
-       LastCreditSerial int64  `key:"Last-Credit-Serial"`
-       LastCert         string `marshal:"id" key:"Last-Cert"`
+       Issuer           string
+       Date             int64
        Difference       int64
-       Draft            string   `marshal:"id"`
-       Drawer           string   `marshal:"id"`
-       DrawerCert       string   `marshal:"id" key:"Drawer-Cert"`
-       Notes            string   // TODO: server or drawer?
-       References       []string `marshal:"idlist"`
+       Draft            string
+       AuthorizedBy     string
+       Notes            string   // optional
+       LastDebitSerial  int64    // 0 if none
+       LastCreditSerial int64    // 0 if none
+       LastCert         string   // ? if serial == 1
+       References       []string // may be empty
 }
 
-func ParseClearSigned(s []byte) (c *ClearSigned, err error) {
-       hash, body, sig := split(s)
-       if len(sig) == 0 {
-               err = fmt.Errorf("ParseClearSigned could parse the signed document")
-               return
-       }
-       c = &ClearSigned{string(hash), trimspace(dashunesc(body)), sig}
-       return
+type CreditCert struct {
+       Holder           string
+       Serial           int64
+       Balance          int64
+       Denomination     string
+       Issuer           string
+       Date             int64
+       Difference       int64
+       Draft            string
+       Drawer           string
+       DebitCert        string
+       AuthorizedBy     string
+       Notes            string   // optional
+       LastDebitSerial  int64    // 0 if none
+       LastCreditSerial int64    // 0 if none
+       LastCert         string   // ? if serial == 1
+       References       []string // may be empty
 }
 
-func FormatClearSigned(c *ClearSigned) (data []byte, err error) {
-       s := ClearSignedHeader
-       if c.Hash != "" {
-               s += "Hash: " + c.Hash + "\n"
+type BounceCert struct {
+       Drawer       string
+       Draft        string
+       LastCert     string // optional
+       Balance      int64  // 0 if none
+       Denomination string
+       Issuer       string
+       Date         int64
+       AuthorizedBy string
+       Notes        string   // optional
+       References   []string // may be empty
+}
+
+// parse an epoint document without checking the signature and format details
+func Parse(s []byte) (iv interface{}, c *Signed, err error) {
+       c, err = ParseSigned(s)
+       if err != nil {
+               return
        }
-       // TODO: check if space was trimmed from body before signature
-       s += "\n"
-       s += string(dashesc(c.Body))
-       s += "\n"
-       s += string(c.ArmoredSignature)
-       data = []byte(s)
+       doc, err := ParseDocument(c.Body)
+       if err != nil {
+               return
+       }
+       iv, err = ParseStruct(doc)
        return
 }
 
-func ParseFields(s []byte) (fields []Field, rest []byte, err error) {
-       rest = s
-       for len(rest) > 0 {
-               var line []byte
-               line, rest = getLine(rest)
-               // empty line after the parsed fields (consumed)
-               if len(line) == 0 {
-                       break
-               }
-               i := bytes.Index(line, []byte(": "))
-               if i < 0 {
-                       err = fmt.Errorf("ParseFields: missing ': '\n")
-                       break
-               }
-               fields = append(fields, Field{string(line[:i]), string(line[i+2:])})
+// format and sign an epoint document
+func Format(iv interface{}, key *openpgp.Entity) (s []byte, err error) {
+       doc, err := FormatStruct(iv)
+       if err != nil {
+               return
        }
-       return
+       body, err := FormatDocument(doc)
+       if err != nil {
+               return
+       }
+       c, err := Sign(body, key)
+       if err != nil {
+               return
+       }
+       return FormatSigned(c)
 }
 
-func ParseBody(s []byte) (doctype string, fields []Field, err error) {
-       // parse content type header first
-       fs, s, err := ParseFields(s)
+// verify an epoint document, return the cleaned version as well
+func Verify(c *Signed, key *openpgp.Entity) (cleaned []byte, err error) {
+       err = CleanSigned(c)
        if err != nil {
                return
        }
-       if len(fs) != 1 || fs[0].Key != "Content-Type" {
-               return "", nil, fmt.Errorf("ParseBody: single Content-Type header was expected\n")
+       err = VerifyCleaned(c, key)
+       if err != nil {
+               return
        }
-       ctype := fs[0].Value
-       for k, v := range ContentType {
-               if ctype == v {
-                       doctype = k
-                       break
+       return FormatSigned(c)
+}
+
+// verify signature of body with given key
+func VerifyCleaned(c *Signed, key *openpgp.Entity) (err error) {
+       kr := openpgp.EntityList{key}
+       msg := bytes.NewBuffer(c.Body)
+       sig := bytes.NewBuffer(c.Signature)
+       _, err = openpgp.CheckArmoredDetachedSignature(kr, msg, sig)
+       return
+}
+
+// sign body with given secret key
+func Sign(body []byte, key *openpgp.Entity) (c *Signed, err error) {
+       c = new(Signed)
+       c.Hash = "SHA256"
+       c.Body = body
+       w := new(bytes.Buffer)
+       err = openpgp.ArmoredDetachSignText(w, key, bytes.NewBuffer(c.Body))
+       c.Signature = w.Bytes()
+       return
+}
+
+// split a clear signed document into body and armored signature
+func ParseSigned(s []byte) (c *Signed, err error) {
+       // look for clear signed header
+       for !bytes.HasPrefix(s, []byte(ClearSignedHeader)) {
+               _, s = getLine(s)
+               if len(s) == 0 {
+                       err = fmt.Errorf("ParseSigned: clear signed header is missing")
+                       return
                }
        }
-       if doctype == "" {
-               return "", nil, fmt.Errorf("ParseBody: unknown Content-Type: %s", ctype)
+       s = s[len(ClearSignedHeader):]
+       // end of line after the header
+       empty, s := getLine(s)
+       if len(empty) != 0 {
+               err = fmt.Errorf("ParseSigned: bad clear signed header")
+               return
        }
-       fields, s, err = ParseFields(s)
-       if err == nil && len(s) > 0 {
-               err = fmt.Errorf("ParseBody: extra data after fields: %q", s)
+       // skip all hash headers, section 7.
+       for bytes.HasPrefix(s, []byte("Hash: ")) {
+               _, s = getLine(s)
+       }
+       // skip empty line
+       empty, s = getLine(s)
+       if len(empty) != 0 {
+               err = fmt.Errorf("ParseSigned: expected an empty line after armor headers")
+               return
        }
+       lines := [][]byte{}
+       for !bytes.HasPrefix(s, []byte("-----BEGIN")) {
+               var line []byte
+               line, s = getLine(s)
+               // dash unescape, section 7.1.
+               if bytes.HasPrefix(line, []byte("- ")) {
+                       line = line[2:]
+               }
+               // empty values are not supported: "Key: \n"
+               lines = append(lines, bytes.TrimRight(line, " \t"))
+       }
+       c = new(Signed)
+       // last line is not closed by \n
+       c.Body = bytes.Join(lines, []byte("\n"))
+       // signature is just the rest of the input data
+       c.Signature = s
        return
 }
 
-func parse(s []byte, doctype string) (v interface{}, err error) {
-       t, fields, err := ParseBody(s)
+// clean up, check and reencode signature
+// used on drafts before calculating the signed document hash
+func CleanSigned(c *Signed) (err error) {
+       b, err := armor.Decode(bytes.NewBuffer(c.Signature))
        if err != nil {
                return
        }
-       if doctype != t && doctype != "" {
-               err = fmt.Errorf("parse: expected doctype %s; got %s", doctype, t)
+       if b.Type != openpgp.SignatureType {
+               err = fmt.Errorf("CleanSigned: invalid armored signature type")
                return
        }
-       switch t {
-       case "draft":
-               v = new(Draft)
-       case "cert":
-               v = new(Cert)
-       default:
-               err = fmt.Errorf("parse: unkown doc type: %s", t)
+       p, err := packet.Read(b.Body)
+       if err != nil {
                return
        }
-       err = unmarshal(fields, v)
-       return
-}
-
-// TODO: limit errors
-func format(v interface{}) ([]byte, error) {
-       doctype := ""
-       switch v.(type) {
-       case *Draft:
-               doctype = "draft"
-       case *Cert:
-               doctype = "cert"
+       sig, ok := p.(*packet.Signature)
+       if !ok {
+               err = fmt.Errorf("CleanSigned: invalid signature packet")
+               return
+       }
+       // section 5.2.3
+       if sig.SigType != packet.SigTypeText {
+               err = fmt.Errorf("CleanSigned: expected text signature")
+               return
+       }
+       switch sig.Hash {
+       case crypto.SHA1:
+               c.Hash = "SHA1"
+       case crypto.SHA256:
+               c.Hash = "SHA256"
        default:
-               panic("reder: unknown type")
+               err = fmt.Errorf("CleanSigned: expected SHA1 or SHA256 signature hash")
+               return
        }
-       s := "Content-Type: " + ContentType[doctype] + "\n\n"
-       fields := marshal(v)
-       for _, f := range fields {
-               s += f.Key + ": " + f.Value + "\n"
+       // TODO: check CreationTime and other subpackets
+       if sig.SigLifetimeSecs != nil && *sig.SigLifetimeSecs != 0 {
+               err = fmt.Errorf("CleanSigned: signature must not expire")
+               return
        }
-       return []byte(s), nil
-}
-
-func ParseDraft(s []byte) (draft *Draft, err error) {
-       v, err := parse(s, "draft")
+       out := new(bytes.Buffer)
+       w, err := armor.Encode(out, openpgp.SignatureType, nil)
        if err != nil {
                return
        }
-       draft = v.(*Draft)
-       return
-}
-
-func FormatDraft(draft *Draft) ([]byte, error) {
-       return format(draft)
-}
-
-func ParseCert(s []byte) (cert *Cert, err error) {
-       v, err := parse(s, "cert")
+       err = sig.Serialize(w)
        if err != nil {
-               return nil, err
+               return
        }
-       cert = v.(*Cert)
-       return
-}
-
-func FormatCert(cert *Cert) ([]byte, error) {
-       return format(cert)
-}
-
-func parseId(s string) (string, error) {
-       if len(s) != 40 {
-               return "", fmt.Errorf("parseId: expected 40 characters; got %d", len(s))
+       err = w.Close()
+       if err != nil {
+               return
        }
-       dst := make([]byte, len(s)/2)
-       _, err := hex.Decode(dst, []byte(s))
-       return s, err
-}
-
-func formatId(s string) string {
-       return s
+       c.Signature = out.Bytes()
+       return
 }
 
-func parseString(s string) (string, error) {
-       if len(s) > 140 {
-               return "", fmt.Errorf("parseString: 140 chars limit is exceeded")
+// create clear signed document
+func FormatSigned(c *Signed) (data []byte, err error) {
+       s := ClearSignedHeader + "\n"
+       if c.Hash != "" {
+               s += "Hash: " + c.Hash + "\n"
        }
-       return s, nil
-}
-
-func formatString(s string) string {
-       return s
+       s += "\n"
+       s += string(c.Body)
+       s += "\n"
+       s += string(c.Signature)
+       data = []byte(s)
+       return
 }
 
-func parseDate(s string) (int64, error) {
-       t, err := time.Parse(time.RFC3339, s)
+// parse type and fields of a document body
+func ParseDocument(body []byte) (doc *Document, err error) {
+       // parse content type header first
+       fields, s, err := ParseFields(body)
        if err != nil {
-               return 0, err
+               return
        }
-       return t.Seconds(), nil
+       ctype, ok := fields["Content-Type"]
+       if len(fields) != 1 || !ok {
+               return nil, fmt.Errorf("ParseBody: expected a single Content-Type header field")
+       }
+       doc = new(Document)
+       for k, v := range ContentType {
+               if ctype == v {
+                       doc.Type = k
+                       break
+               }
+       }
+       if doc.Type == "" {
+               return nil, fmt.Errorf("ParseBody: unknown Content-Type: %s", ctype)
+       }
+       // TODO: doc.Order
+       doc.Fields, s, err = ParseFields(s)
+       if err == nil && len(s) > 0 {
+               err = fmt.Errorf("ParseBody: extra data after fields: %q", s)
+       }
+       return
 }
 
-func formatDate(i int64) string {
-       return time.SecondsToUTC(i).Format(time.RFC3339)
+// create document body
+func FormatDocument(doc *Document) (body []byte, err error) {
+       ctype, ok := ContentType[doc.Type]
+       if !ok {
+               err = fmt.Errorf("FormatDocument: unknown document type: %s", doc.Type)
+               return
+       }
+       s := "Content-Type: " + ctype + "\n\n"
+       for _, k := range doc.Order {
+               s += k + ": " + doc.Fields[k] + "\n"
+       }
+       return []byte(s), nil
 }
 
-func unmarshal(fields []Field, iv interface{}) error {
-       v := reflect.ValueOf(iv)
-       if v.Kind() != reflect.Ptr || v.IsNil() || v.Elem().Kind() != reflect.Struct {
-               panic("unmarshal: input is not a pointer to struct")
+// parse doc fields into a struct according to the document type
+func ParseStruct(doc *Document) (iv interface{}, err error) {
+       switch doc.Type {
+       case "Draft":
+               iv = new(Draft)
+       case "Notice":
+               iv = new(Notice)
+       case "DebitCert":
+               iv = new(DebitCert)
+       case "CreditCert":
+               iv = new(CreditCert)
+       case "BounceCert":
+               iv = new(BounceCert)
+       default:
+               err = fmt.Errorf("ParseStruct: unkown doc type: %s", doc.Type)
+               return
        }
-       v = v.Elem()
+       seen := make(map[string]bool)
+       v := reflect.ValueOf(iv).Elem()
        t := v.Type()
        n := v.NumField()
-       if len(fields) != n {
-               return fmt.Errorf("unmarshal: %s has %d fields, got %d\n", t.Name(), n, len(fields))
-       }
        for i := 0; i < n; i++ {
                ft := t.Field(i)
                fv := v.Field(i)
-               m := ft.Tag.Get("marshal")
-               k := ft.Tag.Get("key")
-               if k == "" {
-                       k = ft.Name
+               key := fieldname[ft.Name]
+               if key == "" {
+                       key = ft.Name
                }
-               if fields[i].Key != k {
-                       return fmt.Errorf("unmarshal: field %d of %s (%s) is missing\n", i+1, t.Name(), k)
+               seen[key] = true
+               s, ok := doc.Fields[key]
+               if !ok {
+                       return nil, fmt.Errorf("ParseStruct: field %s of %s is missing\n", key, t.Name())
                }
-               s := fields[i].Value
-               var err error
-               switch fv.Kind() {
-               case reflect.String:
+               switch fieldtype[key] {
+               case "id":
                        var val string
-                       switch m {
-                       case "id":
-                               val, err = parseId(s)
-                       case "":
-                               val, err = parseString(s)
-                       default:
-                               panic("bad string field tag")
-                       }
+                       val, err = parseId(s)
                        fv.SetString(val)
-               case reflect.Int, reflect.Int32, reflect.Int64:
+               case "text":
+                       var val string
+                       val, err = parseString(s)
+                       fv.SetString(val)
+               case "int":
                        var val int64
-                       switch m {
-                       case "date":
-                               val, err = parseDate(s)
-                       case "":
-                               val, err = strconv.Atoi64(s)
-                       default:
-                               panic("bad int field tag")
-                       }
+                       val, err = strconv.Atoi64(s)
                        fv.SetInt(val)
-               case reflect.Uint, reflect.Uint32, reflect.Uint64:
-                       var val uint64
-                       switch m {
-                       case "":
-                               val, err = strconv.Atoui64(s)
-                       default:
-                               panic("bad uint field tag")
-                       }
-                       fv.SetUint(val)
-               case reflect.Slice:
-                       var val []string
-                       switch m {
-                       case "idlist":
-                               if fv.Type().Elem().Kind() != reflect.String {
-                                       panic("only string slice is supported")
-                               }
-                               ids := strings.Split(s, " ")
-                               val = make([]string, len(ids))
-                               for j := range val {
-                                       val[j], err = parseId(ids[j])
-                                       if err != nil {
-                                               return err
-                                       }
+               case "date":
+                       var val int64
+                       val, err = parseDate(s)
+                       fv.SetInt(val)
+               case "ids":
+                       ids := strings.Split(s, " ")
+                       val := make([]string, len(ids))
+                       for j, id := range ids {
+                               val[j], err = parseId(id)
+                               if err != nil {
+                                       return
                                }
-                       default:
-                               panic("bad slice field tag")
                        }
                        fv.Set(reflect.ValueOf(val))
                default:
-                       panic("bad field type")
+                       panic("bad field type " + key + " " + fieldtype[key])
                }
                if err != nil {
-                       return err
+                       return
+               }
+       }
+       if len(doc.Fields) != n {
+               for k := range doc.Fields {
+                       if !seen[k] {
+                               err = fmt.Errorf("ParseStruct: unknown field %s in %s", k, t.Name())
+                               return
+                       }
                }
        }
-       return nil
+       return
 }
 
-func marshal(iv interface{}) []Field {
+// turn a struct into a document
+func FormatStruct(iv interface{}) (doc *Document, err error) {
        v := reflect.ValueOf(iv)
        if v.Kind() != reflect.Ptr || v.IsNil() || v.Elem().Kind() != reflect.Struct {
-               panic("unmarshal: input is not a pointer to struct")
+               panic("input is not a pointer to struct")
        }
        v = v.Elem()
        t := v.Type()
        n := v.NumField()
-       fields := []Field{}
+       doc = new(Document)
+       doc.Type = t.Name()
+       doc.Fields = make(map[string]string)
        for i := 0; i < n; i++ {
                ft := t.Field(i)
                fv := v.Field(i)
-               m := ft.Tag.Get("marshal")
-               k := ft.Tag.Get("key")
-               if k == "" {
-                       k = ft.Name
+               key := fieldname[ft.Name]
+               if key == "" {
+                       key = ft.Name
                }
                val := ""
-               switch fv.Kind() {
-               case reflect.String:
-                       switch m {
-                       case "id":
-                               val = formatId(fv.String())
-                       case "":
-                               val = formatString(fv.String())
-                       default:
-                               panic("bad string field tag")
-                       }
-               case reflect.Int, reflect.Int32, reflect.Int64:
-                       switch m {
-                       case "date":
-                               val = formatDate(fv.Int())
-                       case "":
-                               val = strconv.Itoa64(fv.Int())
-                       default:
-                               panic("bad int field tag")
-                       }
-               case reflect.Uint, reflect.Uint32, reflect.Uint64:
-                       switch m {
-                       case "":
-                               val = strconv.Uitoa64(fv.Uint())
-                       default:
-                               panic("bad uint field tag")
-                       }
-               case reflect.Slice:
-                       switch m {
-                       case "idlist":
-                               if fv.Type().Elem().Kind() != reflect.String {
-                                       panic("only string slice is supported")
+               switch fieldtype[key] {
+               case "id":
+                       val = formatId(fv.String())
+               case "text":
+                       val = formatString(fv.String())
+               case "int":
+                       val = strconv.Itoa64(fv.Int())
+               case "date":
+                       val = formatDate(fv.Int())
+               case "ids":
+                       k := fv.Len()
+                       for j := 0; j < k; j++ {
+                               if j > 0 {
+                                       val += "\n "
                                }
-                               k := fv.Len()
-                               for j := 0; j < k; j++ {
-                                       if j > 0 {
-                                               val += " "
-                                       }
-                                       val += formatId(fv.Index(j).String())
-                               }
-                       default:
-                               panic("bad slice field tag")
+                               val += formatId(fv.Index(j).String())
                        }
                default:
-                       panic("bad field type")
+                       panic("bad field type " + key + " " + fieldtype[key])
                }
-               fields = append(fields, Field{k, val})
+               doc.Fields[key] = val
+               doc.Order = append(doc.Order, key)
        }
-       return fields
+       return
 }
 
-func getLine(data []byte) (line, rest []byte) {
-       i := bytes.Index(data, []byte{'\n'})
-       j := i + 1
-       if i < 0 {
-               i = len(data)
-               j = i
-       } else if i > 0 && data[i-1] == '\r' {
-               i--
+func ParseFields(s []byte) (fields map[string]string, rest []byte, err error) {
+       rest = s
+       fields = make(map[string]string)
+       lastkey := ""
+       for len(rest) > 0 {
+               var line []byte
+               line, rest = getLine(rest)
+               // empty line after the parsed fields is consumed
+               if len(line) == 0 {
+                       break
+               }
+               // TODO: empty line: " \n"
+               if line[0] == ' ' {
+                       if lastkey == "" {
+                               err = fmt.Errorf("ParseFields: expected a field, not ' '")
+                               return
+                       }
+                       fields[lastkey] += string(line)
+                       continue
+               }
+               // TODO: empty value: "Key: \n"
+               i := bytes.Index(line, []byte(": "))
+               if i < 0 {
+                       err = fmt.Errorf("ParseFields: missing ': '")
+                       break
+               }
+               lastkey = string(line[:i])
+               if _, ok := fields[lastkey]; ok {
+                       err = fmt.Errorf("ParseFields: repeated fields are not allowed")
+                       return
+               }
+               fields[lastkey] = string(line[i+2:])
        }
-       return data[:i], data[j:]
+       return
 }
 
-func trimspace(s []byte) []byte {
-       a := bytes.Split(s, []byte("\n"))
-       for i := range a {
-               a[i] = bytes.TrimRight(a[i], " \t\r")
+// TODO: limit errors
+
+func parseId(s string) (string, error) {
+       if len(s) != 40 {
+               return "", fmt.Errorf("parseId: expected 40 characters; got %d", len(s))
        }
-       return bytes.Join(a, []byte("\n"))
+       dst := make([]byte, len(s)/2)
+       _, err := hex.Decode(dst, []byte(s))
+       return s, err
 }
 
-func dashesc(s []byte) []byte {
-       r := bytes.Replace(s, []byte("\n-"), []byte("\n- -"), -1)
-       if len(r) > 0 && r[0] == '-' {
-               r = append([]byte("- "), r...)
-       }
-       return r
+func formatId(s string) string {
+       return s
 }
 
-func dashunesc(s []byte) []byte {
-       r := bytes.Replace(s, []byte("\n- "), []byte("\n"), -1)
-       if len(r) >= 2 && r[0] == '-' && r[1] == ' ' {
-               r = r[2:]
+func parseString(s string) (string, error) {
+       if len(s) > 140 {
+               return "", fmt.Errorf("parseString: 140 chars limit is exceeded")
        }
-       return r
+       return s, nil
 }
 
-// RFC 4880 is unclear about multiple Hash header semantics, section 7. says
-//  "One or more "Hash" Armor Headers"
-// then
-//  "If more than one message digest is used in the signature, the "Hash"
-//   armor header contains a comma-delimited list of used message digests."
-// in section 6.2.
-//  "there is no limit to the length of Armor Headers.  Care should
-//   be taken that the Armor Headers are short enough to survive
-//   transport.  One way to do this is to repeat an Armor Header key
-//   multiple times with different values for each so that no one line is
-//   overly long."
-// we accept a single Hash header with a list of hash algorithms for now
-// but use the one specified by the signature
-
-func split(s []byte) (hash, body, sig []byte) {
-       if !bytes.HasPrefix(s, []byte(ClearSignedHeader)) {
-               return
-       }
-       s = s[len(ClearSignedHeader):]
-       // only allow a single Hash: header
-       if bytes.HasPrefix(s, []byte("Hash: ")) {
-               s = s[len("Hash: "):]
-               hash, s = getLine(s)
-       }
-       // skip empty line
-       empty, s := getLine(s)
-       if len(empty) != 0 {
-               return
+func formatString(s string) string {
+       return s
+}
+
+func parseDate(s string) (int64, error) {
+       t, err := time.Parse(time.RFC3339, s)
+       if err != nil {
+               return 0, err
        }
-       i := bytes.Index(s, []byte("\n-----BEGIN"))
+       return t.Seconds(), nil
+}
+
+func formatDate(i int64) string {
+       return time.SecondsToUTC(i).Format(time.RFC3339)
+}
+
+func getLine(data []byte) (line, rest []byte) {
+       i := bytes.Index(data, []byte{'\n'})
+       j := i + 1
        if i < 0 {
-               return
-       }
-       body, sig = s[:i], s[i+1:]
-       if i > 0 && body[i-1] == '\r' {
-               body = body[:i-1]
+               i = len(data)
+               j = i
+       } else if i > 0 && data[i-1] == '\r' {
+               i--
        }
-       return
+       return data[:i], data[j:]
 }
index 6113fac..024b0bd 100644 (file)
@@ -4,135 +4,135 @@ import (
        "testing"
 )
 
-const D1 = `-----BEGIN PGP SIGNED MESSAGE-----
+var signedData = []struct {
+       text string
+       ok   bool
+       hash string
+       body string
+       sig  string
+}{
+       {
+               `-----BEGIN PGP SIGNED MESSAGE-----
 Hash: SHA1
 
-Content-Type: text/plain.epoint.cert; charset=utf-8
-
-A: foo
-B: bar
-a: baz
+body
 -----BEGIN PGP SIGNATURE-----
-
 sig
------END PGP SIGNATURE-----
-`
-const D2 = `-----BEGIN PGP SIGNED MESSAGE-----
+`, true, "SHA1", "body", "-----BEGIN PGP SIGNATURE-----\nsig\n"},
+       {
+               `-----BEGIN PGP SIGNED MESSAGE-----
 Hash: SHA1
+Hash: SHA256
 
-Content-Type: text/plain.epoint.cert; charset=utf-8
-
-A: foo
-B: bar
-a: baz
-
+- body  
 -----BEGIN PGP SIGNATURE-----
-
-sig
------END PGP SIGNATURE-----
-`
-
-var C1 = &ClearSigned{
-       Hash: "SHA1",
-       Body: []byte(`Content-Type: text/plain.epoint.cert; charset=utf-8
-
-A: foo
-B: bar
-a: baz`),
-       ArmoredSignature: []byte(`-----BEGIN PGP SIGNATURE-----
-
 sig
------END PGP SIGNATURE-----
-`)}
-
-var C2 = &ClearSigned{
-       Hash: "SHA1",
-       Body: []byte(`Content-Type: text/plain.epoint.cert; charset=utf-8
-
-A: foo
-B: bar
-a: baz
-`),
-       ArmoredSignature: []byte(`-----BEGIN PGP SIGNATURE-----
-
-sig
------END PGP SIGNATURE-----
-`)}
-
-var F = []Field{
-       {"A", "foo"},
-       {"B", "bar"},
-       {"a", "baz"},
+`, false, "", "body", "-----BEGIN PGP SIGNATURE-----\nsig\n"},
 }
 
-var testData = []struct {
-       D []byte
-       C *ClearSigned
-       T string
-       F []Field
-}{
-       {[]byte(D1), C1, "cert", F},
-       {[]byte(D2), C2, "cert", F},
-}
-
-func eqClearSigned(c1, c2 *ClearSigned) bool {
-       return c1.Hash == c2.Hash &&
-               string(c1.Body) == string(c2.Body) &&
-               string(c1.ArmoredSignature) == string(c2.ArmoredSignature)
-}
-
-func eqFields(f1, f2 []Field) bool {
-       if len(f1) != len(f2) {
-               return false
-       }
-       for i, v := range f1 {
-               if f2[i].Key != v.Key && f2[i].Value != v.Value {
-                       return false
-               }
-       }
-       return true
-}
-
-func TestClearSigned(t *testing.T) {
-       for _, x := range testData {
-               c, err := ParseClearSigned(x.D)
+func TestSigned(t *testing.T) {
+       for _, x := range signedData {
+               c, err := ParseSigned([]byte(x.text))
                if err != nil {
-                       t.Errorf("decoding %#v failed: %s\n", x.D, err)
+                       t.Errorf("parsing %q failed: %s\n", x.text, err)
                        continue
                }
-               if !eqClearSigned(c, x.C) {
-                       t.Errorf("expected: %#v, got %#v\n", x.C, c)
+               if string(c.Body) != x.body {
+                       t.Errorf("expected: %q, got %q\n", x.body, c.Body)
+               }
+               if string(c.Signature) != x.sig {
+                       t.Errorf("expected: %q, got %q\n", x.sig, c.Signature)
                }
        }
-       for _, x := range testData {
-               d, err := FormatClearSigned(x.C)
+       for _, x := range signedData {
+               if !x.ok {
+                       continue
+               }
+               c := &Signed{x.hash, []byte(x.body), []byte(x.sig)}
+               s, err := FormatSigned(c)
                if err != nil {
-                       t.Errorf("encoding %#v failed: %s\n", x.C, err)
+                       t.Errorf("formating %#v failed: %s\n", c, err)
                        continue
                }
-               if string(d) != string(x.D) {
-                       t.Errorf("expected: %#v, got %#v\n", x.D, d)
+               if string(s) != x.text {
+                       t.Errorf("expected: %q, got %q\n", x.text, s)
                }
        }
 }
 
-func TestParse(t *testing.T) {
-       for _, x := range testData {
-               tt, f, err := ParseBody(x.C.Body)
+var docData = []struct {
+       text string
+       ok   bool
+       t    string
+       k    []string
+       v    []string
+}{
+       {
+               `Content-Type: text/vnd.epoint.draft; charset=utf-8
+
+K1: v1
+K2-Foo: v 2
+K3: v 3
+`, true, "Draft", []string{"K1", "K2-Foo", "K3"}, []string{"v1", "v 2", "v 3"}},
+       {
+               `Content-Type: text/vnd.epoint.debit; charset=utf-8
+
+K1:  v1
+K2-Foo: v 2
+K3: v 3
+`, true, "DebitCert", []string{"K1", "K2-Foo", "K3"}, []string{" v1", "v 2", "v 3"}},
+       {
+               `Content-Type: text/vnd.epoint.credit; charset=utf-8
+
+K1: v1
+K2-Foo: v 2
+K3: v
+ 3
+`, false, "CreditCert", []string{"K1", "K2-Foo", "K3"}, []string{"v1", "v 2", "v 3"}},
+}
+
+func TestDocument(t *testing.T) {
+       for _, x := range docData {
+               doc, err := ParseDocument([]byte(x.text))
                if err != nil {
-                       t.Errorf("parsing %s failed: %s\n", x.C.Body, err)
+                       t.Errorf("parsing %q failed: %s\n", x.text, err)
+                       continue
+               }
+               if string(doc.Type) != x.t {
+                       t.Errorf("expected: %q, got %q\n", x.t, doc.Type)
+               }
+               if len(doc.Fields) != len(x.k) {
+                       t.Errorf("expected: %d fields, got %d\n", len(x.k), len(doc.Fields))
+               }
+               for i, k := range x.k {
+                       if doc.Fields[k] != x.v[i] {
+                               t.Errorf("value of %s expected to be %s, got %s\n", k, x.v[i], doc.Fields[k])
+                       }
+               }
+       }
+       for _, x := range docData {
+               if !x.ok {
                        continue
                }
-               if !eqFields(f, x.F) {
-                       t.Errorf("expected fields %#v, got %#v\n", x.F, f)
+               doc := new(Document)
+               doc.Type = x.t
+               doc.Fields = make(map[string]string)
+               doc.Order = x.k
+               for i, k := range x.k {
+                       doc.Fields[k] = x.v[i]
                }
-               if tt != x.T {
-                       t.Errorf("expected type %s, got %s\n", x.T, tt)
+               s, err := FormatDocument(doc)
+               if err != nil {
+                       t.Errorf("formating %#v failed: %s\n", doc, err)
+                       continue
+               }
+               if string(s) != x.text {
+                       t.Errorf("expected: %q, got %q\n", x.text, s)
                }
        }
 }
 
-const draftBody = `Content-Type: text/plain.epoint.draft; charset=utf-8
+const draftBody = `Content-Type: text/vnd.epoint.draft; charset=utf-8
 
 Drawer: 000000000000000000000000000000000000000A
 Beneficiary: 000000000000000000000000000000000000000B
@@ -140,59 +140,89 @@ Amount: 1
 Denomination: half euro
 Issuer: 000000000000000000000000000000000000000D
 Authorized-By: 000000000000000000000000000000000000000C
-Date: 2011-11-13T12:20:35Z
 Maturity-Date: 2011-11-13T12:20:35Z
 Expiry-Date: 2011-12-27T09:18:46Z
-Notes: some notes
 Nonce: 42
+Notes: some notes
 `
 
 func TestDraft(t *testing.T) {
-       d, err := ParseDraft([]byte(draftBody))
+       doc, err := ParseDocument([]byte(draftBody))
        if err != nil {
-               t.Errorf("ParseDraft failed: %s\n", err)
+               t.Errorf("ParseDocument failed: %s\n", err)
                return
        }
-       s, err := FormatDraft(d)
+       iv, err := ParseStruct(doc)
        if err != nil {
-               t.Errorf("render %v draft failed: %s\n", d, err)
+               t.Errorf("ParseStruct %v failed: %s\n", doc, err)
+               return
+       }
+       d, ok := iv.(*Draft)
+       if !ok {
+               t.Errorf("expected *Draft got %#v\n", iv)
+               return
+       }
+       doc, err = FormatStruct(d)
+       if err != nil {
+               t.Errorf("format %v draft failed: %s\n", d, err)
+               return
+       }
+       s, err := FormatDocument(doc)
+       if err != nil {
+               t.Errorf("format %v doc failed: %s\n", doc, err)
+               return
        }
        if string(s) != draftBody {
                t.Errorf("parsed %#v\nexpected: %s\ngot: %s\n", d, draftBody, s)
        }
 }
 
-const certBody = `Content-Type: text/plain.epoint.cert; charset=utf-8
+const debitBody = `Content-Type: text/vnd.epoint.debit; charset=utf-8
 
 Holder: 0000000000000000000000000000000000000009
 Serial: 13
-Date: 2011-11-01T10:29:38Z
 Balance: 23
 Denomination: half euro
 Issuer: 000000000000000000000000000000000000000B
+Date: 2011-11-13T12:20:35Z
+Difference: 1
+Draft: 000000000000000000000000000000000000000D
 Authorized-By: 000000000000000000000000000000000000000A
+Notes: -
 Last-Debit-Serial: 0
 Last-Credit-Serial: 12
 Last-Cert: 000000000000000000000000000000000000000C
-Difference: 1
-Draft: 000000000000000000000000000000000000000D
-Drawer: 000000000000000000000000000000000000000E
-Drawer-Cert: 000000000000000000000000000000000000000F
-Notes: -
-References: 000000000000000000000000000000000000000C 000000000000000000000000000000000000000F
+References: 000000000000000000000000000000000000000C
+ 000000000000000000000000000000000000000F
 `
 
 func TestCert(t *testing.T) {
-       c, err := ParseCert([]byte(certBody))
+       doc, err := ParseDocument([]byte(debitBody))
        if err != nil {
-               t.Errorf("ParseCert failed: %s\n", err)
+               t.Errorf("ParseDocument failed: %s\n", err)
                return
        }
-       s, err := FormatCert(c)
+       iv, err := ParseStruct(doc)
        if err != nil {
-               t.Errorf("render %v cert failed: %s\n", c, err)
+               t.Errorf("ParseStruct %v failed: %s\n", doc, err)
+               return
+       }
+       d, ok := iv.(*DebitCert)
+       if !ok {
+               t.Errorf("expected *DebitCert got %#v\n", iv)
+               return
+       }
+       doc, err = FormatStruct(d)
+       if err != nil {
+               t.Errorf("format %v draft failed: %s\n", d, err)
+               return
+       }
+       s, err := FormatDocument(doc)
+       if err != nil {
+               t.Errorf("format %v doc failed: %s\n", doc, err)
+               return
        }
-       if string(s) != certBody {
-               t.Errorf("parsed %#v\nexpected: %s\ngot: %s\n", c, certBody, s)
+       if string(s) != debitBody {
+               t.Errorf("parsed %#v\nexpected: %s\ngot: %s\n", d, debitBody, s)
        }
 }