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
- #183 Accept buffers or strings for cert/key in createServer
- #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

339
lib/dn.js
View File

@ -23,29 +23,143 @@ function isWhitespace(c) {
function RDN(obj) {
var self = this;
this.attrs = {};
if (obj) {
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 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 = '';
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)
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;
};
RDN.prototype.toString = function toString() {
return this.format();
};
// Thank you OpenJDK!
function parse(name) {
if (typeof (name) !== 'string')
@ -53,27 +167,30 @@ function parse(name) {
var cur = 0;
var len = name.length;
var rdnSpaced = false;
function parseRdn() {
var rdn = new RDN();
var order = 0;
rdn.spLead = trim();
while (cur < len) {
var leading = trim();
rdnSpaced = rdnSpaced || (leading > 0);
var opts = {
order: order
};
var attr = parseAttrType();
trim();
if (cur >= len || name[cur++] !== '=')
throw invalidDN(name);
trim();
var value = parseAttrValue();
trim();
rdn[attr.toLowerCase()] = value;
// Parameters about RDN value are set in 'opts' by parseAttrValue
var value = parseAttrValue(opts);
rdn.set(attr, value, opts);
rdn.spTrail = trim();
if (cur >= len || name[cur] !== '+')
break;
++cur;
++order;
}
return rdn;
}
@ -110,10 +227,12 @@ function parse(name) {
return name.slice(beg, cur);
}
function parseAttrValue() {
function parseAttrValue(opts) {
if (cur < len && name[cur] == '#') {
opts.binary = true;
return parseBinaryAttrValue();
} else if (cur < len && name[cur] == '"') {
opts.quoted = true;
return parseQuotedAttrValue();
} else {
return parseStringAttrValue();
@ -129,41 +248,43 @@ function parse(name) {
}
function parseQuotedAttrValue() {
var beg = cur++;
var str = '';
++cur; // Consume the first quote
while ((cur < len) && name[cur] != '"') {
if (name[cur] === '\\')
++cur; // consume backslash, then what follows
++cur;
cur++;
str += name[cur++];
}
if (cur++ >= len) // no closing quote
throw invalidDN(name);
return name.slice(beg, cur);
return str;
}
function parseStringAttrValue() {
var beg = cur;
var str = '';
var esc = -1;
while ((cur < len) && !atTerminator()) {
if (name[cur] === '\\') {
++cur; // consume backslash, then what follows
esc = cur;
// Consume the backslash and mark its place just in case it's escaping
// 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.
var end;
for (end = cur; end > beg; end--) {
if (!isWhitespace(name[end - 1]) || (esc === (end - 1)))
// Trim off (unescaped) trailing whitespace and rewind cursor to the end of
// the AttrValue to record whitespace length.
for (; cur > beg; cur--) {
if (!isWhitespace(name[cur - 1]) || (esc === (cur - 1)))
break;
}
return name.slice(beg, end);
return str.slice(0, cur - beg);
}
function atTerminator() {
@ -175,6 +296,10 @@ function parse(name) {
var rdns = [];
// Short-circuit for empty DNs
if (len === 0)
return new DN(rdns);
rdns.push(parseRdn());
while (cur < len) {
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.rdnSpaced = true;
this._format = {};
this.__defineGetter__('length', function () {
return this.rdns.length;
@ -210,75 +335,109 @@ function DN(rdns) {
}
DN.prototype.toString = function () {
var _dn = [];
this.rdns.forEach(function (rdn) {
_dn.push(rdn.toString());
});
return _dn.join(this.rdnSpaced ? ', ' : ',');
};
DN.prototype.spaced = function (spaces) {
this.rdnSpaced = (spaces === false) ? false : true;
return this;
};
DN.prototype.childOf = function (dn) {
if (typeof (dn) !== 'object')
dn = parse(dn);
if (this.rdns.length <= dn.rdns.length)
return false;
var diff = this.rdns.length - dn.rdns.length;
for (var i = dn.rdns.length - 1; i >= 0; i--) {
var rdn = dn.rdns[i];
var keys = Object.keys(rdn);
if (!keys.length)
return false;
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;
}
/**
* Convert DN to string according to specified formatting options.
*
* Parameters:
* - options: formatting parameters (optional, details below)
*
* 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.
* - Modification options: Alter string formatting defaults.
*
* Preservation options _always_ take precedence over modification options.
*
* Preservation Options:
* - keepOrder: Order of multi-value RDNs.
* - keepQuote: RDN values which were quoted will remain so.
* - keepSpace: Leading/trailing spaces will be output.
* - keepCase: Parsed attr name will be output instead of lowercased version.
*
* Modification Options:
* - upperName: RDN names will be uppercased instead of lowercased.
* - skipSpace: Disable trailing space after RDN separators
*/
DN.prototype.format = function (options) {
if (options) {
if (typeof (options) !== 'object')
throw new TypeError('options must be an object');
} else {
options = this._format;
}
return true;
var str = '';
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')
dn = parse(dn);
if (!this.rdns.length || this.rdns.length >= dn.rdns.length)
if (this.rdns.length >= dn.rdns.length)
return false;
var diff = dn.rdns.length - this.rdns.length;
for (var i = this.rdns.length - 1; i >= 0; i--) {
var rdn = this.rdns[i];
var keys = Object.keys(rdn);
var myRDN = this.rdns[i];
var theirRDN = dn.rdns[i + diff];
if (!keys.length)
if (!myRDN.equals(theirRDN))
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;
};
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) {
if (typeof (dn) !== 'object')
dn = parse(dn);
@ -287,22 +446,8 @@ DN.prototype.equals = function (dn) {
return false;
for (var i = 0; i < this.rdns.length; i++) {
var ours = this.rdns[i];
var theirs = dn.rdns[i];
var ourKeys = Object.keys(ours);
var theirKeys = Object.keys(theirs);
if (ourKeys.length !== theirKeys.length)
if (!this.rdns[i].equals(dn.rdns[i]))
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;
@ -310,7 +455,7 @@ DN.prototype.equals = function (dn) {
DN.prototype.parent = function () {
if (this.rdns.length > 1) {
if (this.rdns.length !== 0) {
var save = this.rdns.shift();
var dn = new DN(this.rdns);
this.rdns.unshift(save);
@ -322,7 +467,9 @@ DN.prototype.parent = 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) {
var reversed = _dn.clone();
reversed.rdns.reverse();
reversedRDNsToKeys[reversed.spaced(true).toString()] = key;
reversedRDNsToKeys[reversed.format()] = key;
}
});
var output = [];

View File

@ -66,6 +66,7 @@ test('parse compound', function (t) {
test('parse quoted', function (t) {
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);
t.ok(name);
t.ok(name.rdns);
@ -74,27 +75,27 @@ test('parse quoted', function (t) {
name.rdns.forEach(function (rdn) {
t.equal('object', typeof (rdn));
});
t.equal(name.toString(), DN_STR);
t.equal(name.toString(), ESCAPE_STR);
t.end();
});
test('equals', function (t) {
var dn1 = dn.parse('cn=foo, dc=bar');
t.ok(dn1.equals('cn=foo, 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=foo2, dc=bar')));
var dn1 = dn.parse('cn=foo,dc=bar');
t.ok(dn1.equals('cn=foo,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=foo2,dc=bar')));
t.end();
});
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=moo'));
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.end();
@ -102,45 +103,104 @@ test('child of', function (t) {
test('parent of', function (t) {
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=bar, dc=foo'));
t.ok(!dn1.parentOf('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=bar,dc=foo'));
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();
});
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 _dn2 = dn.parse('cn=foo');
t.ok(_dn.isEmpty());
t.notOk(_dn2.isEmpty());
t.notOk(_dn.equals('cn=foo'));
t.notOk(_dn2.equals(''));
t.notOk(_dn.parentOf('cn=foo'));
t.ok(_dn.parentOf('cn=foo'));
t.notOk(_dn.childOf('cn=foo'));
t.notOk(_dn2.parentOf(''));
t.notOk(_dn2.childOf(''));
t.ok(_dn2.childOf(''));
t.end();
});
test('case insensitive attribute names GH-20', function (t) {
var dn1 = dn.parse('CN=foo, dc=bar');
t.ok(dn1.equals('cn=foo, dc=bar'));
t.ok(dn1.equals(dn.parse('cn=foo, DC=bar')));
test('case insensitive attribute names', function (t) {
var dn1 = dn.parse('CN=foo,dc=bar');
t.ok(dn1.equals('cn=foo,dc=bar'));
t.ok(dn1.equals(dn.parse('cn=foo,DC=bar')));
t.end();
});
test('rdn spacing', function (t) {
var dn1 = dn.parse('cn=foo,dc=bar');
var dn2 = dn.parse('cn=foo, dc=bar');
t.ok(dn1.equals(dn2));
t.equals(dn1.toString(), 'cn=foo,dc=bar');
t.equals(dn2.toString(), 'cn=foo, dc=bar');
t.equals(dn1.spaced().toString(), 'cn=foo, dc=bar');
t.equals(dn2.spaced(false).toString(), 'cn=foo,dc=bar');
test('format', function (t) {
var DN_ORDER = dn.parse('sn=bar+cn=foo,ou=test');
var DN_QUOTE = dn.parse('cn="foo",ou=test');
var DN_QUOTE2 = dn.parse('cn=" foo",ou=test');
var DN_SPACE = dn.parse('cn=foo,ou=test');
var DN_SPACE2 = dn.parse('cn=foo ,ou=test');
var DN_CASE = dn.parse('CN=foo,Ou=test');
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();
});