Skip to content

Commit dab8811

Browse files
committed
Pad: Fix copyPadWithoutHistory apool corruption bug
1 parent ed78b56 commit dab8811

File tree

4 files changed

+96
-5
lines changed

4 files changed

+96
-5
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# 1.9.0 (not yet released)
22

3-
### Notable enhancements
3+
### Notable enhancements and fixes
4+
5+
* Fixed a potential attribute pool corruption bug with `copyPadWithoutHistory`.
46

57
#### For plugin authors
68

src/node/db/Pad.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,10 +492,9 @@ Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) {
492492

493493
// initialize the pad with a new line to avoid getting the defaultText
494494
const newPad = await padManager.getPad(destinationID, '\n');
495+
newPad.pool = sourcePad.pool.clone();
495496

496497
const oldAText = this.atext;
497-
const newPool = newPad.pool;
498-
newPool.fromJsonable(sourcePad.pool.toJsonable()); // copy that sourceId pool to the new pad
499498

500499
// based on Changeset.makeSplice
501500
const assem = Changeset.smartOpAssembler();

src/static/js/AttributePool.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ class AttributePool {
9191
this.nextNum = 0;
9292
}
9393

94+
/**
95+
* @returns {AttributePool} A deep copy of this attribute pool.
96+
*/
97+
clone() {
98+
const c = new AttributePool();
99+
for (const [n, a] of Object.entries(this.numToAttrib)) c.numToAttrib[n] = [a[0], a[1]];
100+
Object.assign(c.attribToNum, this.attribToNum);
101+
c.nextNum = this.nextNum;
102+
return c;
103+
}
104+
94105
/**
95106
* Add an attribute to the attribute set, or query for an existing attribute identifier.
96107
*
@@ -164,7 +175,10 @@ class AttributePool {
164175

165176
/**
166177
* @returns {Jsonable} An object that can be passed to `fromJsonable` to reconstruct this
167-
* attribute pool. The returned object can be converted to JSON.
178+
* attribute pool. The returned object can be converted to JSON. WARNING: The returned object
179+
* has references to internal state (it is not a deep copy). Use the `clone()` method to copy
180+
* a pool -- do NOT do `new AttributePool().fromJsonable(pool.toJsonable())` to copy because
181+
* the resulting shared state will lead to pool corruption.
168182
*/
169183
toJsonable() {
170184
return {
@@ -177,7 +191,10 @@ class AttributePool {
177191
* Replace the contents of this attribute pool with values from a previous call to `toJsonable`.
178192
*
179193
* @param {Jsonable} obj - Object returned by `toJsonable` containing the attributes and their
180-
* identifiers.
194+
* identifiers. WARNING: This function takes ownership of the object (it does not make a deep
195+
* copy). Use the `clone()` method to copy a pool -- do NOT do
196+
* `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared
197+
* state will lead to pool corruption.
181198
*/
182199
fromJsonable(obj) {
183200
this.numToAttrib = obj.numToAttrib;

src/tests/backend/specs/api/pad.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
const assert = require('assert').strict;
1111
const common = require('../../common');
12+
const padManager = require('../../../../node/db/PadManager');
1213

1314
let agent;
1415
const apiKey = common.apiKey;
@@ -547,6 +548,78 @@ describe(__filename, function () {
547548
assert.equal(res.body.code, 0);
548549
});
549550
});
551+
552+
// Regression test for https://github.com/ether/etherpad-lite/issues/5296
553+
it('source and destination attribute pools are independent', async function () {
554+
// Strategy for this test:
555+
// 1. Create a new pad without bold or italic text
556+
// 2. Use copyPadWithoutHistory to copy the pad.
557+
// 3. Add some bold text (but no italic text!) to the source pad. This should add a bold
558+
// attribute to the source pad's pool but not to the destination pad's pool.
559+
// 4. Add some italic text (but no bold text!) to the destination pad. This should add an
560+
// italic attribute to the destination pad's pool with the same number as the newly added
561+
// bold attribute in the source pad's pool.
562+
// 5. Add some more text (bold or plain) to the source pad. This will save the source pad to
563+
// the database after the destination pad has had an opportunity to corrupt the source
564+
// pad.
565+
// 6. Export the source and destination pads. Make sure that <em> doesn't appear in the
566+
// source pad's HTML, and that <strong> doesn't appear int he destination pad's HTML.
567+
// 7. Force the server to re-init the pads from the database.
568+
// 8. Repeat step 6.
569+
// If <em> appears in the source pad, or <strong> appears in the destination pad, then shared
570+
// state between the two attribute pools caused corruption.
571+
572+
const getHtml = async (padId) => {
573+
const res = await agent.get(`${endPoint('getHTML')}&padID=${padId}`)
574+
.expect(200)
575+
.expect('Content-Type', /json/);
576+
assert.equal(res.body.code, 0);
577+
return res.body.data.html;
578+
};
579+
580+
const setBody = async (padId, bodyHtml) => {
581+
await agent.post(endPoint('setHTML'))
582+
.send({padID: padId, html: `<!DOCTYPE HTML><html><body>${bodyHtml}</body></html>`})
583+
.expect(200)
584+
.expect('Content-Type', /json/)
585+
.expect((res) => assert.equal(res.body.code, 0));
586+
};
587+
588+
const origHtml = await getHtml(sourcePadId);
589+
assert.doesNotMatch(origHtml, /<strong>/);
590+
assert.doesNotMatch(origHtml, /<em>/);
591+
await agent.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}` +
592+
`&destinationID=${newPad}&force=false`)
593+
.expect(200)
594+
.expect('Content-Type', /json/)
595+
.expect((res) => assert.equal(res.body.code, 0));
596+
597+
const newBodySrc = '<strong>bold</strong>';
598+
const newBodyDst = '<em>italic</em>';
599+
await setBody(sourcePadId, newBodySrc);
600+
await setBody(newPad, newBodyDst);
601+
await setBody(sourcePadId, `${newBodySrc} foo`);
602+
603+
let [srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]);
604+
assert.match(srcHtml, new RegExp(newBodySrc));
605+
assert.match(dstHtml, new RegExp(newBodyDst));
606+
607+
// Force the server to re-read the pads from the database. This rebuilds the attribute pool
608+
// objects from scratch, ensuring that an internally inconsistent attribute pool object did
609+
// not cause the above tests to accidentally pass.
610+
const reInitPad = async (padId) => {
611+
const pad = await padManager.getPad(padId);
612+
await pad.init();
613+
};
614+
await Promise.all([
615+
reInitPad(sourcePadId),
616+
reInitPad(newPad),
617+
]);
618+
619+
[srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]);
620+
assert.match(srcHtml, new RegExp(newBodySrc));
621+
assert.match(dstHtml, new RegExp(newBodyDst));
622+
});
550623
});
551624
});
552625

0 commit comments

Comments
 (0)