Overhaul DN parsing and formatting

Certain applications depend upon DN string formatting in a manner more
strict than specified in the RFC.  To enable format transcription and
reproduction, some changes were made to how DNs are converted to/from
strings.

- Store RDN strings raw instead of escaped
- Record formatting details during DN/RDN parsing
- Add DN.format method to control format recreation
- Remove DN.spaced method in favor of DN.setFormat

Fix mcavage/node-ldapjs#176
This commit is contained in:
Patrick Mooney 2015-02-16 14:53:42 -06:00
parent 22b04f3a94
commit 495ea2afce
4 changed files with 336 additions and 125 deletions

View File

@ -24,6 +24,10 @@
- #178 Perform strict presence testing on attribute vals - #178 Perform strict presence testing on attribute vals
- #183 Accept buffers or strings for cert/key in createServer - #183 Accept buffers or strings for cert/key in createServer
- #180 Add '-i, --insecure' option and to all ldapjs-\* CLIs - #180 Add '-i, --insecure' option and to all ldapjs-\* CLIs
- Completely overhaul DN parsing/formatting
- Add options for format preservation
- Removed `spaced()` and `rndSpaced` from DN API
- Fix parent/child rules regarding empty DNs
## 0.7.1 ## 0.7.1

339
lib/dn.js
View File

@ -23,29 +23,143 @@ function isWhitespace(c) {
function RDN(obj) { function RDN(obj) {
var self = this; var self = this;
this.attrs = {};
if (obj) { if (obj) {
Object.keys(obj).forEach(function (k) { Object.keys(obj).forEach(function (k) {
self[k.toLowerCase()] = obj[k]; self.set(k, obj[k]);
}); });
} }
} }
RDN.prototype.toString = function () { RDN.prototype.set = function set(name, value, opts) {
if (typeof (name) !== 'string')
throw new TypeError('name (string) required');
if (typeof (value) !== 'string')
throw new TypeError('value (string) required');
var self = this; var self = this;
var lname = name.toLowerCase();
this.attrs[lname] = {
value: value,
name: name
};
if (opts && typeof (opts) === 'object') {
Object.keys(opts).forEach(function (k) {
if (k !== 'value')
self.attrs[lname][k] = opts[k];
});
}
};
RDN.prototype.equals = function equals(rdn) {
if (typeof (rdn) !== 'object')
return false;
var ourKeys = Object.keys(this.attrs);
var theirKeys = Object.keys(rdn.attrs);
if (ourKeys.length !== theirKeys.length)
return false;
ourKeys.sort();
theirKeys.sort();
for (var i = 0; i < ourKeys.length; i++) {
if (ourKeys[i] !== theirKeys[i])
return false;
if (this.attrs[ourKeys[i]].value !== rdn.attrs[ourKeys[i]].value)
return false;
}
return true;
};
/**
* Convert RDN to string according to specified formatting options.
* (see: DN.format for option details)
*/
RDN.prototype.format = function format(options) {
if (options) {
if (typeof (options) !== 'object')
throw new TypeError('options must be an object');
} else {
options = {};
}
var self = this;
var str = ''; var str = '';
Object.keys(this).forEach(function (k) {
function escapeValue(val, forceQuote) {
var out = '';
var cur = 0;
var len = val.length;
var quoted = false;
/* BEGIN JSSTYLED */
var escaped = /[\\\"]/;
var special = /[,=+<>#;]/;
/* END JSSTYLED */
if (len > 0) {
// Wrap strings with trailing or leading spaces in quotes
quoted = forceQuote || (val[0] == ' ' || val[len-1] == ' ');
}
while (cur < len) {
if (escaped.test(val[cur]) || (!quoted && special.test(val[cur]))) {
out += '\\';
}
out += val[cur++];
}
if (quoted)
out = '"' + out + '"';
return out;
}
function sortParsed(a, b) {
return self.attrs[a].order - self.attrs[b].order;
}
function sortStandard(a, b) {
var nameCompare = a.localeCompare(b);
if (nameCompare === 0) {
// TODO: Handle binary values
return self.attrs[a].value.localeCompare(self.attrs[b].value);
} else {
return nameCompare;
}
}
var keys = Object.keys(this.attrs);
if (options.keepOrder) {
keys.sort(sortParsed);
} else {
keys.sort(sortStandard);
}
keys.forEach(function (key) {
var attr = self.attrs[key];
if (str.length) if (str.length)
str += '+'; str += '+';
str += k + '=' + self[k]; if (options.keepCase) {
str += attr.name;
} else {
if (options.upperName)
str += key.toUpperCase();
else
str += key;
}
str += '=' + escapeValue(attr.value, (options.keepQuote && attr.quoted));
}); });
return str; return str;
}; };
RDN.prototype.toString = function toString() {
return this.format();
};
// Thank you OpenJDK! // Thank you OpenJDK!
function parse(name) { function parse(name) {
if (typeof (name) !== 'string') if (typeof (name) !== 'string')
@ -53,27 +167,30 @@ function parse(name) {
var cur = 0; var cur = 0;
var len = name.length; var len = name.length;
var rdnSpaced = false;
function parseRdn() { function parseRdn() {
var rdn = new RDN(); var rdn = new RDN();
var order = 0;
rdn.spLead = trim();
while (cur < len) { while (cur < len) {
var leading = trim(); var opts = {
rdnSpaced = rdnSpaced || (leading > 0); order: order
};
var attr = parseAttrType(); var attr = parseAttrType();
trim(); trim();
if (cur >= len || name[cur++] !== '=') if (cur >= len || name[cur++] !== '=')
throw invalidDN(name); throw invalidDN(name);
trim(); trim();
var value = parseAttrValue(); // Parameters about RDN value are set in 'opts' by parseAttrValue
trim(); var value = parseAttrValue(opts);
rdn[attr.toLowerCase()] = value; rdn.set(attr, value, opts);
rdn.spTrail = trim();
if (cur >= len || name[cur] !== '+') if (cur >= len || name[cur] !== '+')
break; break;
++cur; ++cur;
++order;
} }
return rdn; return rdn;
} }
@ -110,10 +227,12 @@ function parse(name) {
return name.slice(beg, cur); return name.slice(beg, cur);
} }
function parseAttrValue() { function parseAttrValue(opts) {
if (cur < len && name[cur] == '#') { if (cur < len && name[cur] == '#') {
opts.binary = true;
return parseBinaryAttrValue(); return parseBinaryAttrValue();
} else if (cur < len && name[cur] == '"') { } else if (cur < len && name[cur] == '"') {
opts.quoted = true;
return parseQuotedAttrValue(); return parseQuotedAttrValue();
} else { } else {
return parseStringAttrValue(); return parseStringAttrValue();
@ -129,41 +248,43 @@ function parse(name) {
} }
function parseQuotedAttrValue() { function parseQuotedAttrValue() {
var beg = cur++; var str = '';
++cur; // Consume the first quote
while ((cur < len) && name[cur] != '"') { while ((cur < len) && name[cur] != '"') {
if (name[cur] === '\\') if (name[cur] === '\\')
++cur; // consume backslash, then what follows cur++;
str += name[cur++];
++cur;
} }
if (cur++ >= len) // no closing quote if (cur++ >= len) // no closing quote
throw invalidDN(name); throw invalidDN(name);
return name.slice(beg, cur); return str;
} }
function parseStringAttrValue() { function parseStringAttrValue() {
var beg = cur; var beg = cur;
var str = '';
var esc = -1; var esc = -1;
while ((cur < len) && !atTerminator()) { while ((cur < len) && !atTerminator()) {
if (name[cur] === '\\') { if (name[cur] === '\\') {
++cur; // consume backslash, then what follows // Consume the backslash and mark its place just in case it's escaping
esc = cur; // whitespace which needs to be preserved.
esc = cur++;
} }
++cur; if (cur === len) // backslash followed by nothing
throw invalidDN(name);
str += name[cur++];
} }
if (cur > len) // backslash followed by nothing
throw invalidDN(name);
// Trim off (unescaped) trailing whitespace. // Trim off (unescaped) trailing whitespace and rewind cursor to the end of
var end; // the AttrValue to record whitespace length.
for (end = cur; end > beg; end--) { for (; cur > beg; cur--) {
if (!isWhitespace(name[end - 1]) || (esc === (end - 1))) if (!isWhitespace(name[cur - 1]) || (esc === (cur - 1)))
break; break;
} }
return name.slice(beg, end); return str.slice(0, cur - beg);
} }
function atTerminator() { function atTerminator() {
@ -175,6 +296,10 @@ function parse(name) {
var rdns = []; var rdns = [];
// Short-circuit for empty DNs
if (len === 0)
return new DN(rdns);
rdns.push(parseRdn()); rdns.push(parseRdn());
while (cur < len) { while (cur < len) {
if (name[cur] === ',' || name[cur] === ';') { if (name[cur] === ',' || name[cur] === ';') {
@ -185,7 +310,7 @@ function parse(name) {
} }
} }
return new DN(rdns).spaced(rdnSpaced); return new DN(rdns);
} }
@ -202,7 +327,7 @@ function DN(rdns) {
}); });
this.rdns = rdns.slice(); this.rdns = rdns.slice();
this.rdnSpaced = true; this._format = {};
this.__defineGetter__('length', function () { this.__defineGetter__('length', function () {
return this.rdns.length; return this.rdns.length;
@ -210,75 +335,109 @@ function DN(rdns) {
} }
DN.prototype.toString = function () { /**
var _dn = []; * Convert DN to string according to specified formatting options.
this.rdns.forEach(function (rdn) { *
_dn.push(rdn.toString()); * Parameters:
}); * - options: formatting parameters (optional, details below)
return _dn.join(this.rdnSpaced ? ', ' : ','); *
}; * Options are divided into two types:
* - Preservation options: Using data recorded during parsing, details of the
* original DN are preserved when converting back into a string.
DN.prototype.spaced = function (spaces) { * - Modification options: Alter string formatting defaults.
this.rdnSpaced = (spaces === false) ? false : true; *
return this; * Preservation options _always_ take precedence over modification options.
}; *
* Preservation Options:
* - keepOrder: Order of multi-value RDNs.
DN.prototype.childOf = function (dn) { * - keepQuote: RDN values which were quoted will remain so.
if (typeof (dn) !== 'object') * - keepSpace: Leading/trailing spaces will be output.
dn = parse(dn); * - keepCase: Parsed attr name will be output instead of lowercased version.
*
if (this.rdns.length <= dn.rdns.length) * Modification Options:
return false; * - upperName: RDN names will be uppercased instead of lowercased.
* - skipSpace: Disable trailing space after RDN separators
var diff = this.rdns.length - dn.rdns.length; */
for (var i = dn.rdns.length - 1; i >= 0; i--) { DN.prototype.format = function (options) {
var rdn = dn.rdns[i]; if (options) {
if (typeof (options) !== 'object')
var keys = Object.keys(rdn); throw new TypeError('options must be an object');
if (!keys.length) } else {
return false; options = this._format;
for (var j = 0; j < keys.length; j++) {
var k = keys[j];
var ourRdn = this.rdns[i + diff];
if (ourRdn[k] !== rdn[k])
return false;
}
} }
var str = '';
return true; function repeatChar(c, n) {
var out = '';
var max = n ? n : 0;
for (var i = 0; i < max; i++)
out += c;
return out;
}
this.rdns.forEach(function (rdn) {
var rdnString = rdn.format(options);
if (str.length !== 0) {
str += ',';
}
if (options.keepSpace) {
str += (repeatChar(' ', rdn.spLead) +
rdnString + repeatChar(' ', rdn.spTrail));
} else if (options.skipSpace === true || str.length === 0) {
str += rdnString;
} else {
str += ' ' + rdnString;
}
});
return str;
}; };
DN.prototype.parentOf = function (dn) { /**
* Set default string formatting options.
*/
DN.prototype.setFormat = function setFormat(options) {
if (typeof (options) !== 'object')
throw new TypeError('options must be an object');
this._format = options;
};
DN.prototype.toString = function () {
return this.format();
};
DN.prototype.parentOf = function parentOf(dn) {
if (typeof (dn) !== 'object') if (typeof (dn) !== 'object')
dn = parse(dn); dn = parse(dn);
if (!this.rdns.length || this.rdns.length >= dn.rdns.length) if (this.rdns.length >= dn.rdns.length)
return false; return false;
var diff = dn.rdns.length - this.rdns.length; var diff = dn.rdns.length - this.rdns.length;
for (var i = this.rdns.length - 1; i >= 0; i--) { for (var i = this.rdns.length - 1; i >= 0; i--) {
var rdn = this.rdns[i]; var myRDN = this.rdns[i];
var keys = Object.keys(rdn); var theirRDN = dn.rdns[i + diff];
if (!keys.length) if (!myRDN.equals(theirRDN))
return false; return false;
for (var j = 0; j < keys.length; j++) {
var k = keys[j];
var theirRdn = dn.rdns[i + diff];
if (theirRdn[k] !== rdn[k])
return false;
}
} }
return true; return true;
}; };
DN.prototype.childOf = function childOf(dn) {
if (typeof (dn) !== 'object')
dn = parse(dn);
return dn.parentOf(this);
};
DN.prototype.isEmpty = function isEmpty() {
return (this.rdns.length === 0);
};
DN.prototype.equals = function (dn) { DN.prototype.equals = function (dn) {
if (typeof (dn) !== 'object') if (typeof (dn) !== 'object')
dn = parse(dn); dn = parse(dn);
@ -287,22 +446,8 @@ DN.prototype.equals = function (dn) {
return false; return false;
for (var i = 0; i < this.rdns.length; i++) { for (var i = 0; i < this.rdns.length; i++) {
var ours = this.rdns[i]; if (!this.rdns[i].equals(dn.rdns[i]))
var theirs = dn.rdns[i];
var ourKeys = Object.keys(ours);
var theirKeys = Object.keys(theirs);
if (ourKeys.length !== theirKeys.length)
return false; return false;
ourKeys.sort();
theirKeys.sort();
for (var j = 0; j < ourKeys.length; j++) {
if (ourKeys[j] !== theirKeys[j])
return false;
if (ours[ourKeys[j]] !== theirs[ourKeys[j]])
return false;
}
} }
return true; return true;
@ -310,7 +455,7 @@ DN.prototype.equals = function (dn) {
DN.prototype.parent = function () { DN.prototype.parent = function () {
if (this.rdns.length > 1) { if (this.rdns.length !== 0) {
var save = this.rdns.shift(); var save = this.rdns.shift();
var dn = new DN(this.rdns); var dn = new DN(this.rdns);
this.rdns.unshift(save); this.rdns.unshift(save);
@ -322,7 +467,9 @@ DN.prototype.parent = function () {
DN.prototype.clone = function () { DN.prototype.clone = function () {
return new DN(this.rdns); var dn = new DN(this.rdns);
dn._format = this._format;
return dn;
}; };

View File

@ -775,7 +775,7 @@ Server.prototype._sortedRouteKeys = function _sortedRouteKeys() {
if (_dn instanceof dn.DN) { if (_dn instanceof dn.DN) {
var reversed = _dn.clone(); var reversed = _dn.clone();
reversed.rdns.reverse(); reversed.rdns.reverse();
reversedRDNsToKeys[reversed.spaced(true).toString()] = key; reversedRDNsToKeys[reversed.format()] = key;
} }
}); });
var output = []; var output = [];

View File

@ -66,6 +66,7 @@ test('parse compound', function (t) {
test('parse quoted', function (t) { test('parse quoted', function (t) {
var DN_STR = 'cn="mark+sn=cavage", ou=people, o=joyent'; var DN_STR = 'cn="mark+sn=cavage", ou=people, o=joyent';
var ESCAPE_STR = 'cn=mark\\+sn\\=cavage, ou=people, o=joyent';
var name = dn.parse(DN_STR); var name = dn.parse(DN_STR);
t.ok(name); t.ok(name);
t.ok(name.rdns); t.ok(name.rdns);
@ -74,27 +75,27 @@ test('parse quoted', function (t) {
name.rdns.forEach(function (rdn) { name.rdns.forEach(function (rdn) {
t.equal('object', typeof (rdn)); t.equal('object', typeof (rdn));
}); });
t.equal(name.toString(), DN_STR); t.equal(name.toString(), ESCAPE_STR);
t.end(); t.end();
}); });
test('equals', function (t) { test('equals', function (t) {
var dn1 = dn.parse('cn=foo, dc=bar'); var dn1 = dn.parse('cn=foo,dc=bar');
t.ok(dn1.equals('cn=foo, dc=bar')); t.ok(dn1.equals('cn=foo,dc=bar'));
t.ok(!dn1.equals('cn=foo1, dc=bar')); t.ok(!dn1.equals('cn=foo1,dc=bar'));
t.ok(dn1.equals(dn.parse('cn=foo, dc=bar'))); t.ok(dn1.equals(dn.parse('cn=foo,dc=bar')));
t.ok(!dn1.equals(dn.parse('cn=foo2, dc=bar'))); t.ok(!dn1.equals(dn.parse('cn=foo2,dc=bar')));
t.end(); t.end();
}); });
test('child of', function (t) { test('child of', function (t) {
var dn1 = dn.parse('cn=foo, dc=bar'); var dn1 = dn.parse('cn=foo,dc=bar');
t.ok(dn1.childOf('dc=bar')); t.ok(dn1.childOf('dc=bar'));
t.ok(!dn1.childOf('dc=moo')); t.ok(!dn1.childOf('dc=moo'));
t.ok(!dn1.childOf('dc=foo')); t.ok(!dn1.childOf('dc=foo'));
t.ok(!dn1.childOf('cn=foo, dc=bar')); t.ok(!dn1.childOf('cn=foo,dc=bar'));
t.ok(dn1.childOf(dn.parse('dc=bar'))); t.ok(dn1.childOf(dn.parse('dc=bar')));
t.end(); t.end();
@ -102,45 +103,104 @@ test('child of', function (t) {
test('parent of', function (t) { test('parent of', function (t) {
var dn1 = dn.parse('cn=foo, dc=bar'); var dn1 = dn.parse('cn=foo,dc=bar');
t.ok(dn1.parentOf('cn=moo, cn=foo, dc=bar')); t.ok(dn1.parentOf('cn=moo,cn=foo,dc=bar'));
t.ok(!dn1.parentOf('cn=moo, cn=bar, dc=foo')); t.ok(!dn1.parentOf('cn=moo,cn=bar,dc=foo'));
t.ok(!dn1.parentOf('cn=foo, dc=bar')); t.ok(!dn1.parentOf('cn=foo,dc=bar'));
t.ok(dn1.parentOf(dn.parse('cn=moo, cn=foo, dc=bar'))); t.ok(dn1.parentOf(dn.parse('cn=moo,cn=foo,dc=bar')));
t.end(); t.end();
}); });
test('empty DNs GH-16', function (t) { test('DN parent', function (t) {
var _dn = dn.parse('cn=foo,ou=bar');
var parent1 = _dn.parent();
var parent2 = parent1.parent();
t.ok(parent1.equals('ou=bar'));
t.ok(parent2.equals(''));
t.equal(parent2.parent(), null);
t.end();
});
test('empty DNs', function (t) {
var _dn = dn.parse(''); var _dn = dn.parse('');
var _dn2 = dn.parse('cn=foo'); var _dn2 = dn.parse('cn=foo');
t.ok(_dn.isEmpty());
t.notOk(_dn2.isEmpty());
t.notOk(_dn.equals('cn=foo')); t.notOk(_dn.equals('cn=foo'));
t.notOk(_dn2.equals('')); t.notOk(_dn2.equals(''));
t.notOk(_dn.parentOf('cn=foo')); t.ok(_dn.parentOf('cn=foo'));
t.notOk(_dn.childOf('cn=foo')); t.notOk(_dn.childOf('cn=foo'));
t.notOk(_dn2.parentOf('')); t.notOk(_dn2.parentOf(''));
t.notOk(_dn2.childOf('')); t.ok(_dn2.childOf(''));
t.end(); t.end();
}); });
test('case insensitive attribute names GH-20', function (t) { test('case insensitive attribute names', function (t) {
var dn1 = dn.parse('CN=foo, dc=bar'); var dn1 = dn.parse('CN=foo,dc=bar');
t.ok(dn1.equals('cn=foo, dc=bar')); t.ok(dn1.equals('cn=foo,dc=bar'));
t.ok(dn1.equals(dn.parse('cn=foo, DC=bar'))); t.ok(dn1.equals(dn.parse('cn=foo,DC=bar')));
t.end(); t.end();
}); });
test('rdn spacing', function (t) { test('format', function (t) {
var dn1 = dn.parse('cn=foo,dc=bar'); var DN_ORDER = dn.parse('sn=bar+cn=foo,ou=test');
var dn2 = dn.parse('cn=foo, dc=bar'); var DN_QUOTE = dn.parse('cn="foo",ou=test');
t.ok(dn1.equals(dn2)); var DN_QUOTE2 = dn.parse('cn=" foo",ou=test');
t.equals(dn1.toString(), 'cn=foo,dc=bar'); var DN_SPACE = dn.parse('cn=foo,ou=test');
t.equals(dn2.toString(), 'cn=foo, dc=bar'); var DN_SPACE2 = dn.parse('cn=foo ,ou=test');
t.equals(dn1.spaced().toString(), 'cn=foo, dc=bar'); var DN_CASE = dn.parse('CN=foo,Ou=test');
t.equals(dn2.spaced(false).toString(), 'cn=foo,dc=bar');
t.equal(DN_ORDER.format({keepOrder: false}), 'cn=foo+sn=bar, ou=test');
t.equal(DN_ORDER.format({keepOrder: true}), 'sn=bar+cn=foo, ou=test');
t.equal(DN_QUOTE.format({keepQuote: false}), 'cn=foo, ou=test');
t.equal(DN_QUOTE.format({keepQuote: true}), 'cn="foo", ou=test');
t.equal(DN_QUOTE2.format({keepQuote: false}), 'cn=" foo", ou=test');
t.equal(DN_QUOTE2.format({keepQuote: true}), 'cn=" foo", ou=test');
t.equal(DN_SPACE.format({keepSpace: false}), 'cn=foo, ou=test');
t.equal(DN_SPACE.format({keepSpace: true}), 'cn=foo,ou=test');
t.equal(DN_SPACE.format({skipSpace: true}), 'cn=foo,ou=test');
t.equal(DN_SPACE2.format({keepSpace: false}), 'cn=foo, ou=test');
t.equal(DN_SPACE2.format({keepSpace: true}), 'cn=foo ,ou=test');
t.equal(DN_SPACE2.format({skipSpace: true}), 'cn=foo,ou=test');
t.equal(DN_CASE.format({keepCase: false}), 'cn=foo, ou=test');
t.equal(DN_CASE.format({keepCase: true}), 'CN=foo, Ou=test');
t.equal(DN_CASE.format({upperName: true}), 'CN=foo, OU=test');
t.end();
});
test('set format', function (t) {
var _dn = dn.parse('uid="user", sn=bar+cn=foo, dc=test , DC=com');
t.equal(_dn.toString(), 'uid=user, cn=foo+sn=bar, dc=test, dc=com');
_dn.setFormat({keepOrder: true});
t.equal(_dn.toString(), 'uid=user, sn=bar+cn=foo, dc=test, dc=com');
_dn.setFormat({keepQuote: true});
t.equal(_dn.toString(), 'uid="user", cn=foo+sn=bar, dc=test, dc=com');
_dn.setFormat({keepSpace: true});
t.equal(_dn.toString(), 'uid=user, cn=foo+sn=bar, dc=test , dc=com');
_dn.setFormat({keepCase: true});
t.equal(_dn.toString(), 'uid=user, cn=foo+sn=bar, dc=test, DC=com');
_dn.setFormat({upperName: true});
t.equal(_dn.toString(), 'UID=user, CN=foo+SN=bar, DC=test, DC=com');
t.end();
});
test('format persists across clone', function (t) {
var _dn = dn.parse('uid="user", sn=bar+cn=foo, dc=test , DC=com');
var OUT = 'UID="user", CN=foo+SN=bar, DC=test, DC=com';
_dn.setFormat({keepQuote: true, upperName: true});
var clone = _dn.clone();
t.equals(_dn.toString(), OUT);
t.equals(clone.toString(), OUT);
t.end(); t.end();
}); });