Skip to content

Commit 5e04453

Browse files
committed
Add parsing option to fix broken/missing nulls
1 parent 73c8b21 commit 5e04453

File tree

3 files changed

+175
-14
lines changed

3 files changed

+175
-14
lines changed

readme.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ data.toJs();
6262
*/
6363
```
6464

65+
### Parsing options
66+
67+
The main `parse()` function takes two parameters, the input string, and an options object.
68+
69+
```js
70+
parse( input, options? )
71+
```
72+
73+
|Option|Type|Default|Description|
74+
|---|---|---|---|
75+
|`fixNulls`|Boolean|`false`|Attempt to fix missing/broken null chars in input.<br>Useful when the input was pasted from the clipboard.|
76+
77+
The `fixNulls` option attempts to fix the following scenarios:
78+
79+
- Nulls have been replaced with the Unicode replacement character &#xfffd;. This can happen if the serialized string was output into a HTML page.
80+
- Nulls are missing. This usually happens if the value was copied to the clipboard. If the string byte count was larger than the content, then the following fixes are attempted, depending on the content of the string.
81+
- If the byte count is larger by 1, and the value starts with `lambda_`, then the string is probably a serialized lambda function.
82+
- If the byte count is larger by 2, and the value starts with an asterisk `*`, then the string is probably a protected property.
83+
- If the byte count is larger by 2, and the other scenarios do not apply, the string is probably a private class property.
84+
6585
## JS Value Conversion
6686
6787
Use the `.toJs()` method on the output to convert to native JavaScript types.

src/parse.spec.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,21 +111,58 @@ describe( 'Object', () => {
111111
} );
112112
} );
113113

114-
test( 'Private properties', () => {
115-
const value = parse( 'O:8:"stdClass":2:{s:3:"foo";s:3:"bar";s:16:"\u0000stdClass\u0000secret";s:3:"shh";}' );
114+
test( 'Private/protected properties', () => {
115+
const value = parse( 'O:8:"stdClass":3:{s:3:"foo";s:3:"bar";s:9:"\u0000*\u0000locked";s:9:"\u0000lambda_1";s:16:"\u0000stdClass\u0000secret";s:3:"shh";}' );
116116
expect( value )
117117
.toEqual( new PHPTypes.PHPObject(
118-
73,
118+
105,
119119
new Map( [
120120
[ new PHPTypes.PHPString( 10, 'foo' ), new PHPTypes.PHPString( 10, 'bar' ) ],
121+
[ new PHPTypes.PHPString( 16, '\u0000*\u0000locked' ), new PHPTypes.PHPString( 16, '\u0000lambda_1' ) ],
121122
[ new PHPTypes.PHPString( 24, '\u0000stdClass\u0000secret' ), new PHPTypes.PHPString( 10, 'shh' ) ],
122123
] ),
123124
'stdClass'
124125
) );
125126
expect( value.toJs() )
126127
.toEqual( { foo: 'bar' } );
127128
expect( value.toJs( { private: true } ) )
128-
.toEqual( { foo: 'bar', secret: 'shh' } );
129+
.toEqual( { foo: 'bar', locked: 'lambda_1', secret: 'shh' } );
130+
} );
131+
132+
test( 'Private/protected properties - replaced nulls', () => {
133+
const value = parse( 'O:8:"stdClass":3:{s:3:"foo";s:3:"bar";s:9:"\ufffd*\ufffdlocked";s:9:"\ufffdlambda_1";s:16:"\ufffdstdClass\ufffdsecret";s:3:"shh";}', { fixNulls: true } );
134+
expect( value )
135+
.toEqual( new PHPTypes.PHPObject(
136+
105,
137+
new Map( [
138+
[ new PHPTypes.PHPString( 10, 'foo' ), new PHPTypes.PHPString( 10, 'bar' ) ],
139+
[ new PHPTypes.PHPString( 16, '\u0000*\u0000locked' ), new PHPTypes.PHPString( 16, '\u0000lambda_1' ) ],
140+
[ new PHPTypes.PHPString( 24, '\u0000stdClass\u0000secret' ), new PHPTypes.PHPString( 10, 'shh' ) ],
141+
] ),
142+
'stdClass'
143+
) );
144+
expect( value.toJs() )
145+
.toEqual( { foo: 'bar' } );
146+
expect( value.toJs( { private: true } ) )
147+
.toEqual( { foo: 'bar', locked: 'lambda_1', secret: 'shh' } );
148+
} );
149+
150+
test( 'Private/protected properties - missing nulls', () => {
151+
const value = parse( 'O:8:"stdClass":3:{s:3:"foo";s:3:"bar";s:9:"*locked";s:9:"lambda_1";s:16:"stdClasssecret";s:3:"shh";}', { fixNulls: true } );
152+
expect( value )
153+
.toEqual( new PHPTypes.PHPObject(
154+
100,
155+
new Map( [
156+
[ new PHPTypes.PHPString( 10, 'foo' ), new PHPTypes.PHPString( 10, 'bar' ) ],
157+
[ new PHPTypes.PHPString( 14, '\u0000*\u0000locked' ), new PHPTypes.PHPString( 15, '\u0000lambda_1' ) ],
158+
[ new PHPTypes.PHPString( 22, '\u0000\u0000stdClasssecret' ), new PHPTypes.PHPString( 10, 'shh' ) ],
159+
] ),
160+
'stdClass'
161+
) );
162+
expect( value.toJs() )
163+
.toEqual( { foo: 'bar' } );
164+
expect( value.toJs( { private: true } ) )
165+
.toEqual( { foo: 'bar', locked: 'lambda_1', secret: 'shh' } );
129166
} );
130167

131168
} );
@@ -246,6 +283,38 @@ describe( 'String', () => {
246283
.toBe( '🐊' );
247284
} );
248285

286+
test( 'Fix broken nulls - utf-8 replacement character', () => {
287+
const value = parse( 's:16:"\ufffdstdClass\ufffdsecret";', { fixNulls: true } );
288+
expect( value )
289+
.toEqual( new PHPTypes.PHPString( 24, '\u0000stdClass\u0000secret' ) );
290+
expect( value.toJs() )
291+
.toEqual( '\u0000stdClass\u0000secret' );
292+
} );
293+
294+
test( 'Fix missing nulls - protected property', () => {
295+
const value = parse( 's:9:"*secret";', { fixNulls: true } );
296+
expect( value )
297+
.toEqual( new PHPTypes.PHPString( 14, '\u0000*\u0000secret' ) );
298+
expect( value.toJs() )
299+
.toEqual( '\u0000*\u0000secret' );
300+
} );
301+
302+
test( 'Fix missing nulls - private property', () => {
303+
const value = parse( 's:16:"stdClasssecret";', { fixNulls: true } );
304+
expect( value )
305+
.toEqual( new PHPTypes.PHPString( 22, '\u0000\u0000stdClasssecret' ) );
306+
expect( value.toJs() )
307+
.toEqual( '\u0000\u0000stdClasssecret' );
308+
} );
309+
310+
test( 'Fix missing nulls - lambda', () => {
311+
const value = parse( 's:9:"lambda_1";', { fixNulls: true } );
312+
expect( value )
313+
.toEqual( new PHPTypes.PHPString( 15, '\u0000lambda_1' ) );
314+
expect( value.toJs() )
315+
.toEqual( 'lambda_1' );
316+
} );
317+
249318
test( 'Missing opening delimiter', () => {
250319
expect( () => parse( 's:1:a";' ) )
251320
.toThrowError( 'Failed to parse fixed-length string' );

src/parse.ts

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
1-
let objectReferences: any[] = [ null ]; // 1-indexed
1+
let objectReferences: any[];
22

3-
export function parse( input: string, resetReferences = true ) {
3+
export type ParseOptions = {
4+
fixNulls: boolean,
5+
};
6+
7+
let parseOptions: Partial<ParseOptions> = {};
8+
9+
export function parse( input: string, options: Partial<ParseOptions> = {} ) {
10+
11+
objectReferences = [ null ];
12+
13+
parseOptions = options;
14+
15+
return _parse( input );
16+
17+
}
18+
19+
function _parse( input: string ) {
420

521
if ( typeof input !== 'string' ) {
622
throw new TypeError( 'Input must be a string' );
@@ -10,10 +26,6 @@ export function parse( input: string, resetReferences = true ) {
1026

1127
const tokenIdentifier = input.substr( 0, 1 ) as PHPTypes.Identifiers;
1228

13-
if ( resetReferences ) {
14-
objectReferences = [ null ];
15-
}
16-
1729
if ( tokenIdentifier in PHPTypes.identifierMap ) {
1830
return PHPTypes.identifierMap[ tokenIdentifier ].build( input );
1931
} else {
@@ -99,7 +111,59 @@ export function parseFixedLengthString( input: string, openingDelimiter = '"', c
99111
if ( input.substr( offset, closingDelimiter.length ) === closingDelimiter ) {
100112
offset += closingDelimiter.length;
101113
} else {
102-
throw new Error( 'Failed to parse fixed-length string' );
114+
115+
if ( parseOptions.fixNulls ) {
116+
// Let's see what we can do about this
117+
118+
// Maybe the nulls have been converted into a replacement character.
119+
// This is the easiest to fix.
120+
if ( value.substr( 0, 1 ) === '\ufffd' ) {
121+
input = input.replace( /\ufffd/g, '\u0000' );
122+
return parseFixedLengthString( input, openingDelimiter, closingDelimiter );
123+
}
124+
125+
// Maybe the nulls are missing, and we overshot the end of the string.
126+
127+
let nullCount: number;
128+
const valueStart = byteCountMatches[ 0 ].length + openingDelimiter.length;
129+
130+
// Check for lambdas. String should have ended one byte early, and the value should start with "lambda_".
131+
nullCount = 1;
132+
if (
133+
decoder.decode( allBytes.slice( byteCount - nullCount, byteCount - nullCount + closingDelimiter.length + 1 ) ) === closingDelimiter + ';'
134+
&& /^lambda_\d+$/.test( value.substr( 0, value.length - nullCount ) )
135+
) {
136+
input = input.substr( 0, valueStart ) + '\u0000' + input.substr( valueStart );
137+
let [ value ] = parseFixedLengthString( input, openingDelimiter, closingDelimiter );
138+
return [ value, offset - nullCount + closingDelimiter.length ]; // Original offset to keep everything matched up
139+
}
140+
141+
// Check for protected properties with a leading asterisk. String should have ended two bytes early.
142+
nullCount = 2;
143+
if (
144+
decoder.decode( allBytes.slice( byteCount - nullCount, byteCount - nullCount + closingDelimiter.length + 1 ) ) === closingDelimiter + ';'
145+
&& value.substr( 0, 1 ) === '*'
146+
) {
147+
input = input.replace( '*', '\u0000*\u0000' );
148+
let [ value ] = parseFixedLengthString( input, openingDelimiter, closingDelimiter );
149+
return [ value, offset - nullCount + closingDelimiter.length ]; // Original offset to keep everything matched up
150+
}
151+
152+
// Check for private properties. String should have ended two bytes early.
153+
nullCount = 2;
154+
if ( decoder.decode( allBytes.slice( byteCount - nullCount, byteCount - nullCount + closingDelimiter.length + 1 ) ) === closingDelimiter + ';' ) {
155+
// Can't determine the class name from here.
156+
// Just prefix with two nulls and check in the toJS method.
157+
input = input.substr( 0, valueStart ) + '\u0000\u0000' + input.substr( valueStart );
158+
let [ value ] = parseFixedLengthString( input, openingDelimiter, closingDelimiter );
159+
return [ value, offset - nullCount + closingDelimiter.length ]; // Original offset to keep everything matched up
160+
}
161+
162+
} else {
163+
164+
throw new Error( 'Failed to parse fixed-length string' );
165+
166+
}
103167
}
104168

105169
return [ value, offset ];
@@ -268,13 +332,13 @@ export namespace PHPTypes {
268332

269333
for ( let i = 0; i < count; i++ ) {
270334

271-
const key = parse( input.substr( offset ), false ) as unknown as K;
335+
const key = _parse( input.substr( offset ) ) as unknown as K;
272336
offset += key.length;
273337

274338
// Keys cannot be referenced
275339
objectReferences.pop();
276340

277-
const value = parse( input.substr( offset ), false );
341+
const value = _parse( input.substr( offset ) );
278342
offset += value.length;
279343

280344
map.set( key, value );
@@ -361,7 +425,9 @@ export namespace PHPTypes {
361425

362426
if ( typeof key === 'string' && key.charCodeAt( 0 ) === 0 ) {
363427
if ( options.private ) {
364-
key = key.replace( /\u0000.+\u0000/, '' );
428+
key = key.replace( new RegExp( `\u0000(\\*|${instance.className})\u0000` ), '' );
429+
// Also handle double nulls caused by fixing missing nulls
430+
key = key.replace( new RegExp( `\u0000\u0000${instance.className}` ), '' );
365431
} else {
366432
continue;
367433
}
@@ -500,6 +566,12 @@ export namespace PHPTypes {
500566
}
501567

502568
static toJs( instance: PHPString ) {
569+
570+
// Remove nulls from lambdas
571+
if ( /^\u0000lambda_\d+$/.test( instance.value ) ) {
572+
return instance.value.replace( /\u0000/g, '' );
573+
}
574+
503575
return instance.value;
504576
}
505577

0 commit comments

Comments
 (0)