diff --git a/README.md b/README.md index fa36c29..174cda6 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ -Utilities for working with ldif data +# ldif + +Utilities for working with ldif data. This implements most of RFC 2849. + +## Change Entries + +Support for moddn / modrdn changes is missing (in Unmarshal and +Marshal) - gopkg.in/ldap.v2 does not support it currently + +## Controls +Only simple controls without control value are supported, currently +just + Manage DSA IT - oid: 2.16.840.1.113730.3.4.2 + +## URLs + +URL schemes in an LDIF like + jpegPhoto;binary:< file:///usr/share/photos/someone.jpg +are only supported for the "file" scheme like in the example above diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..bdbdb92 --- /dev/null +++ b/apply.go @@ -0,0 +1,57 @@ +package ldif + +import ( + "fmt" + "log" + + "gopkg.in/ldap.v2" +) + +// Apply sends the LDIF entries to the server and does the changes as +// given by the entries. +// +// All *ldap.Entry are converted to an *ldap.AddRequest. +// +// By default, it returns on the first error. To continue with applying the +// LDIF, set the continueOnErr argument to true - in this case the errors +// are logged with log.Printf() +func (l *LDIF) Apply(conn ldap.Client, continueOnErr bool) error { + for _, entry := range l.Entries { + switch { + case entry.Entry != nil: + add := ldap.NewAddRequest(entry.Entry.DN) + for _, attr := range entry.Entry.Attributes { + add.Attribute(attr.Name, attr.Values) + } + entry.Add = add + fallthrough + case entry.Add != nil: + if err := conn.Add(entry.Add); err != nil { + if continueOnErr { + log.Printf("ERROR: Failed to add %s: %s", entry.Add.DN, err) + continue + } + return fmt.Errorf("failed to add %s: %s", entry.Add.DN, err) + } + + case entry.Del != nil: + if err := conn.Del(entry.Del); err != nil { + if continueOnErr { + log.Printf("ERROR: Failed to delete %s: %s", entry.Del.DN, err) + continue + } + return fmt.Errorf("failed to delete %s: %s", entry.Del.DN, err) + } + + case entry.Modify != nil: + if err := conn.Modify(entry.Modify); err != nil { + if continueOnErr { + log.Printf("ERROR: Failed to modify %s: %s", entry.Modify.DN, err) + continue + } + return fmt.Errorf("failed to modify %s: %s", entry.Modify.DN, err) + } + } + } + return nil +} diff --git a/changes_test.go b/changes_test.go new file mode 100644 index 0000000..72bffc2 --- /dev/null +++ b/changes_test.go @@ -0,0 +1,91 @@ +package ldif_test + +import ( + "testing" + + "github.com/go-ldap/ldif" +) + +var ldifRFC2849Example6 = `version: 1 +# Add a new entry +dn: cn=Fiona Jensen, ou=Marketing, dc=airius, dc=com +changetype: add +objectclass: top +objectclass: person +objectclass: organizationalPerson +cn: Fiona Jensen +sn: Jensen +uid: fiona +telephonenumber: +1 408 555 1212 +# jpegphoto:< file:///usr/local/directory/photos/fiona.jpg + +# Delete an existing entry +dn: cn=Robert Jensen, ou=Marketing, dc=airius, dc=com +changetype: delete + +# Modify an entry's relative distinguished name +#dn: cn=Paul Jensen, ou=Product Development, dc=airius, dc=com +#changetype: modrdn +#newrdn: cn=Paula Jensen +#deleteoldrdn: 1 + +# Rename an entry and move all of its children to a new location in +# the directory tree (only implemented by LDAPv3 servers). +#dn: ou=PD Accountants, ou=Product Development, dc=airius, dc=com +#changetype: modrdn +#newrdn: ou=Product Development Accountants +#deleteoldrdn: 0 +#newsuperior: ou=Accounting, dc=airius, dc=com + +# Modify an entry: add an additional value to the postaladdress +# attribute, completely delete the description attribute, replace +# the telephonenumber attribute with two values, and delete a specific +# value from the facsimiletelephonenumber attribute +dn: cn=Paula Jensen, ou=Product Development, dc=airius, dc=com +changetype: modify +add: postaladdress +postaladdress: 123 Anystreet $ Sunnyvale, CA $ 94086 +- +# the example in the RFC has an empty line here, I don't think that's allowed... +delete: description +- +replace: telephonenumber +telephonenumber: +1 408 555 1234 +telephonenumber: +1 408 555 5678 +- +delete: facsimiletelephonenumber +facsimiletelephonenumber: +1 408 555 9876 +- + +# Modify an entry: replace the postaladdress attribute with an empty +# set of values (which will cause the attribute to be removed), and +# delete the entire description attribute. Note that the first will +# always succeed, while the second will only succeed if at least +# one value for the description attribute is present. +dn: cn=Ingrid Jensen, ou=Product Support, dc=airius, dc=com +changetype: modify +replace: postaladdress +- +delete: description +- +` + +func TestLDIFParseRFC2849Example6(t *testing.T) { + l, err := ldif.Parse(ldifRFC2849Example6) + if err != nil { + t.Errorf("Failed to parse RFC 2849 example #6: %s", err) + } + if len(l.Entries) != 4 { // != 6 + t.Errorf("invalid number of entries parsed: %d", len(l.Entries)) + } + if l.Entries[3].Modify == nil { + t.Errorf("last entry not a modify request") + } + if l.Entries[3].Modify.DeleteAttributes[0].Type != "description" { + t.Errorf("RFC 2849 example 6: no deletion of description in last entry") + } + if l.Entries[2].Modify.ReplaceAttributes[0].Type != "telephonenumber" && + l.Entries[2].Modify.ReplaceAttributes[0].Vals[1] != "+1 408 555 5678" { + t.Errorf("RFC 2849 example 6: no replacing of telephonenumber") + } +} diff --git a/ldif.go b/ldif.go new file mode 100644 index 0000000..fd8fc6b --- /dev/null +++ b/ldif.go @@ -0,0 +1,527 @@ +// Package ldif contains an LDIF parser and marshaller (RFC 2849). +package ldif + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "strconv" + "strings" + + "gopkg.in/ldap.v2" +) + +// Entry is one entry in the LDIF +type Entry struct { + Entry *ldap.Entry + Add *ldap.AddRequest + Del *ldap.DelRequest + Modify *ldap.ModifyRequest +} + +// The LDIF struct is used for parsing an LDIF. The Controls +// is used to tell the parser to ignore any controls found +// when parsing (default: false to ignore the controls). +// FoldWidth is used for the line lenght when marshalling. +type LDIF struct { + Entries []*Entry + Version int + changeType string + FoldWidth int + Controls bool + firstEntry bool +} + +// The ParseError holds the error message and the line in the ldif +// where the error occurred. +type ParseError struct { + Line int + Message string +} + +// Error implements the error interface +func (e *ParseError) Error() string { + return fmt.Sprintf("Error in line %d: %s", e.Line, e.Message) +} + +var cr byte = '\x0D' +var lf byte = '\x0A' +var sep = string([]byte{cr, lf}) +var comment byte = '#' +var space byte = ' ' +var spaces = string(space) + +// Parse wraps Unmarshal to parse an LDIF from a string +func Parse(str string) (l *LDIF, err error) { + buf := bytes.NewBuffer([]byte(str)) + l = &LDIF{} + err = Unmarshal(buf, l) + return +} + +// ParseWithControls wraps Unmarshal to parse an LDIF from +// a string, controls are added to change records +func ParseWithControls(str string) (l *LDIF, err error) { + buf := bytes.NewBuffer([]byte(str)) + l = &LDIF{Controls: true} + err = Unmarshal(buf, l) + return +} + +// Unmarshal parses the LDIF from the given io.Reader into the LDIF struct. +// The caller is responsible for closing the io.Reader if that is +// needed. +func Unmarshal(r io.Reader, l *LDIF) (err error) { + if r == nil { + return &ParseError{Line: 0, Message: "No reader present"} + } + curLine := 0 + l.Version = 0 + l.changeType = "" + isComment := false + + reader := bufio.NewReader(r) + + var lines []string + var line, nextLine string + l.firstEntry = true + + for { + curLine++ + nextLine, err = reader.ReadString(lf) + nextLine = strings.TrimRight(nextLine, sep) + + switch err { + case nil, io.EOF: + switch len(nextLine) { + case 0: + if len(line) == 0 && err == io.EOF { + return nil + } + if len(line) == 0 && len(lines) == 0 { + continue + } + lines = append(lines, line) + entry, perr := l.parseEntry(lines) + if perr != nil { + return &ParseError{Line: curLine, Message: perr.Error()} + } + l.Entries = append(l.Entries, entry) + line = "" + lines = []string{} + if err == io.EOF { + return nil + } + default: + switch nextLine[0] { + case comment: + isComment = true + continue + + case space: + if isComment { + continue + } + line += nextLine[1:] + continue + + default: + isComment = false + if len(line) != 0 { + lines = append(lines, line) + } + line = nextLine + continue + } + } + default: + return &ParseError{Line: curLine, Message: err.Error()} + } + } +} + +func (l *LDIF) parseEntry(lines []string) (entry *Entry, err error) { + if len(lines) == 0 { + return nil, errors.New("empty entry?") + } + + if l.firstEntry && strings.HasPrefix(lines[0], "version:") { + l.firstEntry = false + line := strings.TrimLeft(lines[0][8:], spaces) + if l.Version, err = strconv.Atoi(line); err != nil { + return nil, err + } + + if l.Version != 1 { + return nil, errors.New("Invalid version spec " + string(line)) + } + + l.Version = 1 + if len(lines) == 1 { + return nil, nil + } + lines = lines[1:] + } + l.firstEntry = false + + if len(lines) == 0 { + return nil, nil + } + + if !strings.HasPrefix(lines[0], "dn:") { + return nil, errors.New("Missing dn:") + } + _, val, err := l.parseLine(lines[0]) + if err != nil { + return nil, err + } + dn := val + + if len(lines) == 1 { + return nil, errors.New("only a dn: line") + } + lines = lines[1:] + + var controls []ldap.Control + controls, lines, err = l.parseControls(lines) + if err != nil { + return nil, err + } + + if strings.HasPrefix(lines[0], "changetype:") { + _, val, err := l.parseLine(lines[0]) + if err != nil { + return nil, err + } + l.changeType = val + if len(lines) > 1 { + lines = lines[1:] + } + } + switch l.changeType { + case "": + if len(controls) != 0 { + return nil, errors.New("controls found without changetype") + } + attrs, err := l.parseAttrs(lines) + if err != nil { + return nil, err + } + return &Entry{Entry: ldap.NewEntry(dn, attrs)}, nil + + case "add": + attrs, err := l.parseAttrs(lines) + if err != nil { + return nil, err + } + // FIXME: controls for add - see https://github.com/go-ldap/ldap/issues/81 + add := ldap.NewAddRequest(dn) + for attr, vals := range attrs { + add.Attribute(attr, vals) + } + return &Entry{Add: add}, nil + + case "delete": + if len(lines) > 1 { + return nil, errors.New("no attributes allowed for changetype delete") + } + return &Entry{Del: ldap.NewDelRequest(dn, controls)}, nil + + case "modify": + // FIXME: controls for modify - see https://github.com/go-ldap/ldap/issues/81 + mod := ldap.NewModifyRequest(dn) + var op, attribute string + var values []string + if lines[len(lines)-1] != "-" { + return nil, errors.New("modify request does not close with a single dash") + } + + for i := 0; i < len(lines); i++ { + if lines[i] == "-" { + switch op { + case "": + return nil, fmt.Errorf("empty operation") + case "add": + mod.Add(attribute, values) + op = "" + case "replace": + mod.Replace(attribute, values) + op = "" + case "delete": + mod.Delete(attribute, values) + op = "" + default: + return nil, fmt.Errorf("invalid operation %s in modify request", op) + } + continue + } + attr, val, err := l.parseLine(lines[i]) + if err != nil { + return nil, err + } + if op == "" { + op = attr + attribute = val + } else { + if attr != attribute { + return nil, fmt.Errorf("invalid attribute %s in %s request for %s", attr, op, attribute) + } + } + } + return &Entry{Modify: mod}, nil + + case "moddn", "modrdn": + return nil, fmt.Errorf("unsupported changetype %s", l.changeType) + + default: + return nil, fmt.Errorf("invalid changetype %s", l.changeType) + } +} + +func (l *LDIF) parseAttrs(lines []string) (map[string][]string, error) { + attrs := make(map[string][]string) + for i := 0; i < len(lines); i++ { + attr, val, err := l.parseLine(lines[i]) + if err != nil { + return nil, err + } + attrs[attr] = append(attrs[attr], val) + } + return attrs, nil +} + +func (l *LDIF) parseLine(line string) (attr, val string, err error) { + off := 0 + for len(line) > off && line[off] != ':' { + off++ + if off >= len(line) { + err = fmt.Errorf("Missing : in line `%s`", line) + return + } + } + if off == len(line) { + err = fmt.Errorf("Missing : in the line `%s`", line) + return + } + + if off > len(line)-2 { + err = errors.New("empty value") + // FIXME: this is allowed for some attributes, e.g. seeAlso + return + } + + attr = line[0:off] + if err = validAttr(attr); err != nil { + attr = "" + val = "" + return + } + + switch line[off+1] { + case ':': + val, err = decodeBase64(strings.TrimLeft(line[off+2:], spaces)) + if err != nil { + return + } + + case '<': + val, err = readURLValue(strings.TrimLeft(line[off+2:], spaces)) + if err != nil { + return + } + + default: + val = strings.TrimLeft(line[off+1:], spaces) + } + + return +} + +func (l *LDIF) parseControls(lines []string) ([]ldap.Control, []string, error) { + var controls []ldap.Control + for { + if !strings.HasPrefix(lines[0], "control:") { + break + } + if !l.Controls { + if len(lines) == 1 { + return nil, nil, errors.New("only controls found") + } + lines = lines[1:] + continue + } + + _, val, err := l.parseLine(lines[0]) + if err != nil { + return nil, nil, err + } + + var oid, ctrlValue string + criticality := false + + parts := strings.SplitN(val, " ", 3) + if err = validOID(parts[0]); err != nil { + return nil, nil, fmt.Errorf("%s is not a valid oid: %s", oid, err) + } + oid = parts[0] + + if len(parts) > 1 { + switch parts[1] { + case "true": + criticality = true + if len(parts) > 2 { + parts[1] = parts[2] + parts = parts[0:2] + } + case "false": + criticality = false + if len(parts) > 2 { + parts[1] = parts[2] + parts = parts[0:2] + } + } + } + if len(parts) == 2 { + ctrlValue = parts[1] + } + if ctrlValue == "" { + switch oid { + case ldap.ControlTypeManageDsaIT: + controls = append(controls, &ldap.ControlManageDsaIT{Criticality: criticality}) + default: + return nil, nil, fmt.Errorf("unsupported control found: %s", oid) + } + } else { + switch ctrlValue[0] { // where is this documented? + case ':': + if len(ctrlValue) == 1 { + return nil, nil, errors.New("missing value for base64 encoded control value") + } + ctrlValue, err = decodeBase64(strings.TrimLeft(ctrlValue[1:], spaces)) + if err != nil { + return nil, nil, err + } + if ctrlValue == "" { + return nil, nil, errors.New("base64 decoded to empty value") + } + + case '<': + if len(ctrlValue) == 1 { + return nil, nil, errors.New("missing value for url control value") + } + ctrlValue, err = readURLValue(strings.TrimLeft(ctrlValue[1:], spaces)) + if err != nil { + return nil, nil, err + } + if ctrlValue == "" { + return nil, nil, errors.New("url resolved to an empty value") + } + } + // TODO: + // convert ctrlValue to *ber.Packet and decode with something like + // ctrl := ldap.DecodeControl() + // ... FIXME: the controls need a Decode() interface + // so we can just do a + // ctrl := ldap.ControlByOID(oid) // returns an empty &ControlSomething{} + // ctrl.Decode((*ber.Packet)(ctrlValue)) + // ctrl.Criticality = criticality + // that should be usable in github.com/go-ldap/ldap/control.go also + // to decode the incoming control + // controls = append(controls, ctrl) + return nil, nil, fmt.Errorf("controls with values are not supported, oid: %s", oid) + } + + if len(lines) == 1 { + return nil, nil, errors.New("only controls found") + } + lines = lines[1:] + } + return controls, lines, nil +} + +func readURLValue(val string) (string, error) { + u, err := url.Parse(val) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %s", err) + } + if u.Scheme != "file" { + return "", fmt.Errorf("unsupported URL scheme %s", u.Scheme) + } + data, err := ioutil.ReadFile(u.Path) + if err != nil { + return "", fmt.Errorf("failed to read %s: %s", u.Path, err) + } + val = string(data) // FIXME: safe? + return val, nil +} + +func decodeBase64(enc string) (string, error) { + dec := make([]byte, base64.StdEncoding.DecodedLen(len([]byte(enc)))) + n, err := base64.StdEncoding.Decode(dec, []byte(enc)) + if err != nil { + return "", err + } + return string(dec[:n]), nil +} + +func validOID(oid string) error { + lastDot := true + for _, c := range oid { + switch { + case c == '.' && lastDot: + return errors.New("OID with at least 2 consecutive dots") + case c == '.': + lastDot = true + case c >= '0' && c <= '9': + lastDot = false + default: + return errors.New("Invalid character in OID") + } + } + return nil +} + +func validAttr(attr string) error { + if len(attr) == 0 { + return errors.New("empty attribute name") + } + switch { + case attr[0] >= 'A' && attr[0] <= 'Z': + // A-Z + case attr[0] >= 'a' && attr[0] <= 'z': + // a-z + default: + if attr[0] >= '0' && attr[0] <= '9' { + return validOID(attr) + } + return errors.New("invalid first character in attribute") + } + for i := 1; i < len(attr); i++ { + c := attr[i] + switch { + case c >= '0' && c <= '9': + case c >= 'A' && c <= 'Z': + case c >= 'a' && c <= 'z': + case c == '-': + case c == ';': + default: + return errors.New("invalid character in attribute name") + } + } + return nil +} + +// AllEntries returns all *ldap.Entries in the LDIF +func (l *LDIF) AllEntries() (entries []*ldap.Entry) { + for _, entry := range l.Entries { + if entry.Entry != nil { + entries = append(entries, entry.Entry) + } + } + return entries +} diff --git a/ldif_test.go b/ldif_test.go new file mode 100644 index 0000000..08c7957 --- /dev/null +++ b/ldif_test.go @@ -0,0 +1,259 @@ +package ldif_test + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/go-ldap/ldif" +) + +var ldifRFC2849Example = `version: 1 +dn: cn=Barbara Jensen, ou=Product Development, dc=airius, dc=com +objectclass: top +objectclass: person +objectclass: organizationalPerson +cn: Barbara Jensen +cn: Barbara J Jensen +cn: Babs Jensen +sn: Jensen +uid: bjensen +telephonenumber: +1 408 555 1212 +description: A big sailing fan. + +dn: cn=Bjorn Jensen, ou=Accounting, dc=airius, dc=com +objectclass: top +objectclass: person +objectclass: organizationalPerson +cn: Bjorn Jensen +sn: Jensen +telephonenumber: +1 408 555 1212 +` + +func TestLDIFParseRFC2849Example(t *testing.T) { + l, err := ldif.Parse(ldifRFC2849Example) + if err != nil { + t.Errorf("Failed to parse RFC 2849 example: %s", err) + } + if l.Entries[1].Entry.GetAttributeValues("sn")[0] != "Jensen" { + t.Errorf("RFC 2849 example: empty 'sn' in second entry") + } +} + +var ldifEmpty = `dn: uid=someone,dc=example,dc=org +cn: +cn: Some User +` + +func TestLDIFParseEmptyAttr(t *testing.T) { + _, err := ldif.Parse(ldifEmpty) + if err == nil { + t.Errorf("Did not fail to parse empty attribute") + } +} + +var ldifMissingDN = `objectclass: top +cn: Some User +` + +func TestLDIFParseMissingDN(t *testing.T) { + _, err := ldif.Parse(ldifMissingDN) + if err == nil { + t.Errorf("Did not fail to parse missing DN attribute") + } +} + +var ldifContinuation = `dn: uid=someone,dc=example,dc=org +sn: Some + One +cn: Someone +` + +func TestLDIFContinuation(t *testing.T) { + l, err := ldif.Parse(ldifContinuation) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + e := l.Entries[0] + if e.Entry.GetAttributeValues("sn")[0] != "Some One" { + t.Errorf("Value of continuation line wrong") + } +} + +var ldifBase64 = `dn: uid=someone,dc=example,dc=org +sn:: U29tZSBPbmU= +` + +func TestLDIFBase64(t *testing.T) { + l, err := ldif.Parse(ldifBase64) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + + e := l.Entries[0] + val := e.Entry.GetAttributeValues("sn")[0] + cmp := "Some One" + if val != cmp { + t.Errorf("Value of base64 value wrong: >%v< >%v<", []byte(val), []byte(cmp)) + } +} + +var ldifBase64Broken = `dn: uid=someone,dc=example,dc=org +sn:: XXX-U29tZSBPbmU= +` + +func TestLDIFBase64Broken(t *testing.T) { + _, err := ldif.Parse(ldifBase64Broken) + if err == nil { + t.Errorf("Did not failed to parse broken base64") + } +} + +var ldifTrailingBlank = `dn: uid=someone,dc=example,dc=org +sn:: U29tZSBPbmU= + +` + +func TestLDIFTrailingBlank(t *testing.T) { + _, err := ldif.Parse(ldifTrailingBlank) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } +} + +var ldifComments = `dn: uid=someone,dc=example,dc=org +# a comment + continued comment +sn: someone +` + +func TestLDIFComments(t *testing.T) { + l, err := ldif.Parse(ldifComments) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + if l.Entries[0].Entry.GetAttributeValues("sn")[0] != "someone" { + t.Errorf("No sn attribute") + } +} + +var ldifNoSpace = `dn:uid=someone,dc=example,dc=org +sn:someone +` + +func TestLDIFNoSpace(t *testing.T) { + l, err := ldif.Parse(ldifNoSpace) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + if l.Entries[0].Entry.GetAttributeValues("sn")[0] != "someone" { + t.Errorf("No/wrong sn attribute: '%s'", l.Entries[0].Entry.GetAttributeValues("sn")[0]) + } +} + +var ldifMultiSpace = `dn: uid=someone,dc=example,dc=org +sn: someone +` + +func TestLDIFMultiSpace(t *testing.T) { + l, err := ldif.Parse(ldifMultiSpace) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + if l.Entries[0].Entry.GetAttributeValues("sn")[0] != "someone" { + t.Errorf("No/wrong sn attribute: '%s'", l.Entries[0].Entry.GetAttributeValues("sn")[0]) + } +} + +func TestLDIFURL(t *testing.T) { + f, err := ioutil.TempFile("", "ldifurl") + if err != nil { + t.Errorf("Failed to create temp file: %s", err) + } + defer os.Remove(f.Name()) + f.Write([]byte("TEST\n")) + f.Sync() + l, err := ldif.Parse("dn: uid=someone,dc=example,dc=org\ndescription:< file://" + f.Name() + "\n") + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + if l.Entries[0].Entry.GetAttributeValues("description")[0] != "TEST\n" { + t.Errorf("wrong file?") + } +} + +var ldifMultiBlankLines = `# Organization Units +dn: ou=users,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: users + + +# searches for above empty line for dn but fails and errors out in this PR +# Even though this is a valid LDIF file for ldapadd +dn: ou=groups,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: groups +` + +func TestLDIFMultiBlankLines(t *testing.T) { + l, err := ldif.Parse(ldifMultiBlankLines) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + ou := l.Entries[1].Entry.GetAttributeValue("ou") + if ou != "groups" { + t.Errorf("wrong ou in second entry: %s", ou) + } +} + +var ldifLeadingTrailingBlankLines = ` + +# Organization Units +dn: ou=users,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: users + + +` + +func TestLDIFLeadingTrailingBlankLines(t *testing.T) { + l, err := ldif.Parse(ldifLeadingTrailingBlankLines) + if err != nil { + t.Errorf("Failed to parse LDIF: %s", err) + } + ou := l.Entries[0].Entry.GetAttributeValue("ou") + if ou != "users" { + t.Errorf("wrong ou in entry: %s", ou) + } +} + +var ldifVersionOnSecond = `dn: cn=Barbara Jensen, ou=Product Development, dc=airius, dc=com +objectclass: top +objectclass: person +objectclass: organizationalPerson +cn: Barbara Jensen +cn: Barbara J Jensen +cn: Babs Jensen +sn: Jensen +uid: bjensen +telephonenumber: +1 408 555 1212 +description: A big sailing fan. + +version: 1 +dn: cn=Bjorn Jensen, ou=Accounting, dc=airius, dc=com +objectclass: top +objectclass: person +objectclass: organizationalPerson +cn: Bjorn Jensen +sn: Jensen +telephonenumber: +1 408 555 1212 +` + +func TestLDIFVersionOnSecond(t *testing.T) { + if _, err := ldif.Parse(ldifVersionOnSecond); err == nil { + t.Errorf("did not fail to parse LDIF") + } +} diff --git a/marshal.go b/marshal.go new file mode 100644 index 0000000..51c3c48 --- /dev/null +++ b/marshal.go @@ -0,0 +1,241 @@ +package ldif + +import ( + "encoding/base64" + "errors" + "fmt" + "gopkg.in/ldap.v2" + "io" +) + +var foldWidth = 76 + +// ErrMixed is the error, that we cannot mix change records and content +// records in one LDIF +var ErrMixed = errors.New("cannot mix change records and content records") + +// Marshal returns an LDIF string from the given LDIF. +// +// The default line lenght is 76 characters. This can be changed by setting +// the fw parameter to something else than 0. +// For a fold width < 0, no folding will be done, with 0, the default is used. +func Marshal(l *LDIF) (data string, err error) { + hasEntry := false + hasChange := false + + if l.Version > 0 { + data = "version: 1\n" + } + + fw := l.FoldWidth + if fw == 0 { + fw = foldWidth + } + + for _, e := range l.Entries { + switch { + case e.Add != nil: + hasChange = true + if hasEntry { + return "", ErrMixed + } + data += foldLine("dn: "+e.Add.DN, fw) + "\n" + data += "changetype: add\n" + for _, add := range e.Add.Attributes { + if len(add.Vals) == 0 { + return "", errors.New("changetype 'add' requires non empty value list") + } + for _, v := range add.Vals { + ev, t := encodeValue(v) + col := ": " + if t { + col = ":: " + } + data += foldLine(add.Type+col+ev, fw) + "\n" + } + } + + case e.Del != nil: + hasChange = true + if hasEntry { + return "", ErrMixed + } + data += foldLine("dn: "+e.Del.DN, fw) + "\n" + data += "changetype: delete\n" + + case e.Modify != nil: + hasChange = true + if hasEntry { + return "", ErrMixed + } + data += foldLine("dn: "+e.Modify.DN, fw) + "\n" + data += "changetype: modify\n" + for _, mod := range e.Modify.AddAttributes { + if len(mod.Vals) == 0 { + return "", errors.New("changetype 'modify', op 'add' requires non empty value list") + } + + data += "add: " + mod.Type + "\n" + for _, v := range mod.Vals { + ev, t := encodeValue(v) + col := ": " + if t { + col = ":: " + } + data += foldLine(mod.Type+col+ev, fw) + "\n" + } + data += "-\n" + } + for _, mod := range e.Modify.DeleteAttributes { + data += "delete: " + mod.Type + "\n" + for _, v := range mod.Vals { + ev, t := encodeValue(v) + col := ": " + if t { + col = ":: " + } + data += foldLine(mod.Type+col+ev, fw) + "\n" + } + data += "-\n" + } + for _, mod := range e.Modify.ReplaceAttributes { + if len(mod.Vals) == 0 { + return "", errors.New("changetype 'modify', op 'replace' requires non empty value list") + } + data += "replace: " + mod.Type + "\n" + for _, v := range mod.Vals { + ev, t := encodeValue(v) + col := ": " + if t { + col = ":: " + } + data += foldLine(mod.Type+col+ev, fw) + "\n" + } + data += "-\n" + } + + default: + hasEntry = true + if hasChange { + return "", ErrMixed + } + data += foldLine("dn: "+e.Entry.DN, fw) + "\n" + for _, av := range e.Entry.Attributes { + for _, v := range av.Values { + ev, t := encodeValue(v) + col := ": " + if t { + col = ":: " + } + data += foldLine(av.Name+col+ev, fw) + "\n" + } + } + } + data += "\n" + } + return data, nil +} + +func encodeValue(value string) (string, bool) { + required := false + for _, r := range value { + if r < ' ' || r > '~' { // ~ = 0x7E, = 0x7F + required = true + break + } + } + if !required { + return value, false + } + return base64.StdEncoding.EncodeToString([]byte(value)), true +} + +func foldLine(line string, fw int) (folded string) { + if fw < 0 { + return line + } + if len(line) <= fw { + return line + } + + folded = line[:fw] + "\n" + line = line[fw:] + + for len(line) > fw-1 { + folded += " " + line[:fw-1] + "\n" + line = line[fw-1:] + } + + if len(line) > 0 { + folded += " " + line + } + return +} + +// Dump writes the given entries to the io.Writer. +// +// The entries argument can be *ldap.Entry or a mix of *ldap.AddRequest, +// *ldap.DelRequest, *ldap.ModifyRequest and *ldap.ModifyDNRequest or slices +// of any of those. +// +// See Marshal() for the fw argument. +func Dump(fh io.Writer, fw int, entries ...interface{}) error { + l, err := ToLDIF(entries...) + if err != nil { + return err + } + l.FoldWidth = fw + str, err := Marshal(l) + if err != nil { + return err + } + _, err = fh.Write([]byte(str)) + return err +} + +// ToLDIF puts the given arguments in an LDIF struct and returns it. +// +// The entries argument can be *ldap.Entry or a mix of *ldap.AddRequest, +// *ldap.DelRequest, *ldap.ModifyRequest and *ldap.ModifyDNRequest or slices +// of any of those. +func ToLDIF(entries ...interface{}) (*LDIF, error) { + l := &LDIF{} + for _, e := range entries { + switch e.(type) { + case []*ldap.Entry: + for _, en := range e.([]*ldap.Entry) { + l.Entries = append(l.Entries, &Entry{Entry: en}) + } + + case *ldap.Entry: + l.Entries = append(l.Entries, &Entry{Entry: e.(*ldap.Entry)}) + + case []*ldap.AddRequest: + for _, en := range e.([]*ldap.AddRequest) { + l.Entries = append(l.Entries, &Entry{Add: en}) + } + + case *ldap.AddRequest: + l.Entries = append(l.Entries, &Entry{Add: e.(*ldap.AddRequest)}) + + case []*ldap.DelRequest: + for _, en := range e.([]*ldap.DelRequest) { + l.Entries = append(l.Entries, &Entry{Del: en}) + } + + case *ldap.DelRequest: + l.Entries = append(l.Entries, &Entry{Del: e.(*ldap.DelRequest)}) + + case []*ldap.ModifyRequest: + for _, en := range e.([]*ldap.ModifyRequest) { + l.Entries = append(l.Entries, &Entry{Modify: en}) + } + case *ldap.ModifyRequest: + l.Entries = append(l.Entries, &Entry{Modify: e.(*ldap.ModifyRequest)}) + + default: + return nil, fmt.Errorf("unsupported type %T", e) + } + } + return l, nil +} diff --git a/marshal_test.go b/marshal_test.go new file mode 100644 index 0000000..d74fb68 --- /dev/null +++ b/marshal_test.go @@ -0,0 +1,246 @@ +package ldif_test + +import ( + "bytes" + "github.com/go-ldap/ldif" + "gopkg.in/ldap.v2" + "testing" +) + +var personLDIF = `dn: uid=someone,ou=people,dc=example,dc=org +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: someone +cn: Someone +mail: someone@example.org + +` + +var ouLDIF = `dn: ou=people,dc=example,dc=org +objectClass: top +objectClass: organizationalUnit +ou: people + +` + +var entries = []*ldap.Entry{ + { + DN: "ou=people,dc=example,dc=org", + Attributes: []*ldap.EntryAttribute{ + { + Name: "objectClass", + Values: []string{ + "top", + "organizationalUnit", + }, + }, + { + Name: "ou", + Values: []string{"people"}, + }, + }, + }, + { + DN: "uid=someone,ou=people,dc=example,dc=org", + Attributes: []*ldap.EntryAttribute{ + { + Name: "objectClass", + Values: []string{ + "top", + "person", + "organizationalPerson", + "inetOrgPerson", + }, + }, + { + Name: "uid", + Values: []string{"someone"}, + }, + { + Name: "cn", + Values: []string{"Someone"}, + }, + { + Name: "mail", + Values: []string{"someone@example.org"}, + }, + }, + }, +} + +func TestMarshalSingleEntry(t *testing.T) { + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Entry: entries[1]}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != personLDIF { + t.Errorf("unexpected result: >>%s<<\n", res) + } +} + +func TestMarshalEntries(t *testing.T) { + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Entry: entries[0]}, + {Entry: entries[1]}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != ouLDIF+personLDIF { + t.Errorf("unexpected result: >>%s<<\n", res) + } +} + +func TestMarshalB64(t *testing.T) { + entryLDIF := `dn: ou=people,dc=example,dc=org +objectClass: top +objectClass: organizationalUnit +ou: people +description:: VGhlIFBlw7ZwbGUgw5ZyZ2FuaXphdGlvbg== + +` + entry := &ldap.Entry{ + DN: "ou=people,dc=example,dc=org", + Attributes: []*ldap.EntryAttribute{ + { + Name: "objectClass", + Values: []string{ + "top", + "organizationalUnit", + }, + }, + { + Name: "ou", + Values: []string{"people"}, + }, + { + Name: "description", + Values: []string{"The Peöple Örganization"}, + }, + }, + } + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Entry: entry}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != entryLDIF { + t.Errorf("unexpected result: >>%s<<\n", res) + } +} + +func TestMarshalMod(t *testing.T) { + modLDIF := `dn: uid=someone,ou=people,dc=example,dc=org +changetype: modify +add: givenName +givenName: Some +- +delete: mail +- +delete: telephoneNumber +telephoneNumber: 123 456 789 - 0 +- +replace: sn +sn: One +- + +` + mod := ldap.NewModifyRequest("uid=someone,ou=people,dc=example,dc=org") + mod.Replace("sn", []string{"One"}) + mod.Add("givenName", []string{"Some"}) + mod.Delete("mail", []string{}) + mod.Delete("telephoneNumber", []string{"123 456 789 - 0"}) + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Modify: mod}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != modLDIF { + t.Errorf("unexpected result: >>%s<<", res) + } +} + +func TestMarshalAdd(t *testing.T) { + addLDIF := `dn: uid=someone,ou=people,dc=example,dc=org +changetype: add +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: someone +cn: Someone +mail: someone@example.org + +` + add := ldap.NewAddRequest("uid=someone,ou=people,dc=example,dc=org") + for _, a := range entries[1].Attributes { + add.Attribute(a.Name, a.Values) + } + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Add: add}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != addLDIF { + t.Errorf("unexpected result: >>%s<<", res) + } +} + +func TestMarshalDel(t *testing.T) { + delLDIF := `dn: uid=someone,ou=people,dc=example,dc=org +changetype: delete + +` + del := ldap.NewDelRequest("uid=someone,ou=people,dc=example,dc=org", nil) + l := &ldif.LDIF{ + Entries: []*ldif.Entry{ + {Del: del}, + }, + } + res, err := ldif.Marshal(l) + if err != nil { + t.Errorf("Failed to marshal entry: %s", err) + } + if res != delLDIF { + t.Errorf("unexpected result: >>%s<<", res) + } +} + +func TestDump(t *testing.T) { + delLDIF := `dn: uid=someone,ou=people,dc=example,dc=org +changetype: delete + +` + del := ldap.NewDelRequest("uid=someone,ou=people,dc=example,dc=org", nil) + buf := bytes.NewBuffer(nil) + err := ldif.Dump(buf, 0, del) + if err != nil { + t.Errorf("Failed to dump entry: %s", err) + } + res := buf.String() + if res != delLDIF { + t.Errorf("unexpected result: >>%s<<", res) + } +}