@fluidframework/debugger 1.4.0-121020 → 2.0.0-dev-rc.1.0.0.224419

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.eslintrc.js +9 -10
  2. package/CHANGELOG.md +117 -0
  3. package/README.md +33 -33
  4. package/api-extractor-lint.json +4 -0
  5. package/api-extractor.json +2 -2
  6. package/api-report/debugger.api.md +157 -0
  7. package/dist/debugger-alpha.d.ts +36 -0
  8. package/dist/debugger-beta.d.ts +35 -0
  9. package/dist/debugger-public.d.ts +35 -0
  10. package/dist/debugger-untrimmed.d.ts +193 -0
  11. package/dist/{fluidDebugger.js → fluidDebugger.cjs} +12 -4
  12. package/dist/fluidDebugger.cjs.map +1 -0
  13. package/dist/fluidDebugger.d.ts +8 -2
  14. package/dist/fluidDebugger.d.ts.map +1 -1
  15. package/dist/{fluidDebuggerController.js → fluidDebuggerController.cjs} +29 -56
  16. package/dist/fluidDebuggerController.cjs.map +1 -0
  17. package/dist/fluidDebuggerController.d.ts +6 -2
  18. package/dist/fluidDebuggerController.d.ts.map +1 -1
  19. package/dist/{fluidDebuggerUi.js → fluidDebuggerUi.cjs} +45 -50
  20. package/dist/fluidDebuggerUi.cjs.map +1 -0
  21. package/dist/fluidDebuggerUi.d.ts +9 -0
  22. package/dist/fluidDebuggerUi.d.ts.map +1 -1
  23. package/dist/index.cjs +14 -0
  24. package/dist/index.cjs.map +1 -0
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/{messageSchema.js → messageSchema.cjs} +1 -12
  28. package/dist/messageSchema.cjs.map +1 -0
  29. package/dist/messageSchema.d.ts.map +1 -1
  30. package/dist/{sanitize.js → sanitize.cjs} +2 -2
  31. package/dist/sanitize.cjs.map +1 -0
  32. package/dist/{sanitizer.js → sanitizer.cjs} +29 -34
  33. package/dist/sanitizer.cjs.map +1 -0
  34. package/dist/sanitizer.d.ts.map +1 -1
  35. package/dist/tsdoc-metadata.json +11 -0
  36. package/lib/debugger-alpha.d.mts +36 -0
  37. package/lib/debugger-beta.d.mts +35 -0
  38. package/lib/debugger-public.d.mts +35 -0
  39. package/lib/debugger-untrimmed.d.mts +193 -0
  40. package/lib/{fluidDebugger.d.ts → fluidDebugger.d.mts} +8 -2
  41. package/lib/fluidDebugger.d.mts.map +1 -0
  42. package/lib/{fluidDebugger.js → fluidDebugger.mjs} +11 -3
  43. package/lib/fluidDebugger.mjs.map +1 -0
  44. package/lib/{fluidDebuggerController.d.ts → fluidDebuggerController.d.mts} +7 -3
  45. package/lib/fluidDebuggerController.d.mts.map +1 -0
  46. package/lib/{fluidDebuggerController.js → fluidDebuggerController.mjs} +20 -47
  47. package/lib/fluidDebuggerController.mjs.map +1 -0
  48. package/lib/{fluidDebuggerUi.d.ts → fluidDebuggerUi.d.mts} +9 -0
  49. package/lib/fluidDebuggerUi.d.mts.map +1 -0
  50. package/lib/{fluidDebuggerUi.js → fluidDebuggerUi.mjs} +44 -49
  51. package/lib/fluidDebuggerUi.mjs.map +1 -0
  52. package/lib/index.d.mts +8 -0
  53. package/lib/index.d.mts.map +1 -0
  54. package/lib/index.mjs +8 -0
  55. package/lib/index.mjs.map +1 -0
  56. package/lib/messageSchema.d.mts.map +1 -0
  57. package/lib/{messageSchema.js → messageSchema.mjs} +1 -12
  58. package/lib/messageSchema.mjs.map +1 -0
  59. package/lib/{sanitize.js → sanitize.mjs} +2 -16
  60. package/lib/sanitize.mjs.map +1 -0
  61. package/lib/{sanitizer.d.ts → sanitizer.d.mts} +0 -14
  62. package/lib/sanitizer.d.mts.map +1 -0
  63. package/lib/{sanitizer.js → sanitizer.mjs} +19 -42
  64. package/lib/sanitizer.mjs.map +1 -0
  65. package/lib/test/types/validateDebuggerPrevious.generated.d.mts +2 -0
  66. package/lib/test/types/validateDebuggerPrevious.generated.d.mts.map +1 -0
  67. package/lib/test/types/{validateDebuggerPrevious.js → validateDebuggerPrevious.generated.mjs} +5 -5
  68. package/lib/test/types/validateDebuggerPrevious.generated.mjs.map +1 -0
  69. package/package.json +76 -44
  70. package/prettier.config.cjs +8 -0
  71. package/src/fluidDebugger.ts +38 -30
  72. package/src/fluidDebuggerController.ts +353 -323
  73. package/src/fluidDebuggerUi.ts +316 -293
  74. package/src/index.ts +3 -3
  75. package/src/messageSchema.ts +356 -367
  76. package/src/sanitize.ts +29 -29
  77. package/src/sanitizer.ts +702 -651
  78. package/tsconfig.json +13 -15
  79. package/dist/fluidDebugger.js.map +0 -1
  80. package/dist/fluidDebuggerController.js.map +0 -1
  81. package/dist/fluidDebuggerUi.js.map +0 -1
  82. package/dist/index.js +0 -20
  83. package/dist/index.js.map +0 -1
  84. package/dist/messageSchema.js.map +0 -1
  85. package/dist/sanitize.js.map +0 -1
  86. package/dist/sanitizer.js.map +0 -1
  87. package/images/Screenshot1.jpg +0 -0
  88. package/images/Screenshot2.jpg +0 -0
  89. package/lib/fluidDebugger.d.ts.map +0 -1
  90. package/lib/fluidDebugger.js.map +0 -1
  91. package/lib/fluidDebuggerController.d.ts.map +0 -1
  92. package/lib/fluidDebuggerController.js.map +0 -1
  93. package/lib/fluidDebuggerUi.d.ts.map +0 -1
  94. package/lib/fluidDebuggerUi.js.map +0 -1
  95. package/lib/index.d.ts +0 -8
  96. package/lib/index.d.ts.map +0 -1
  97. package/lib/index.js +0 -8
  98. package/lib/index.js.map +0 -1
  99. package/lib/messageSchema.d.ts.map +0 -1
  100. package/lib/messageSchema.js.map +0 -1
  101. package/lib/sanitize.js.map +0 -1
  102. package/lib/sanitizer.d.ts.map +0 -1
  103. package/lib/sanitizer.js.map +0 -1
  104. package/lib/test/types/validateDebuggerPrevious.d.ts +0 -2
  105. package/lib/test/types/validateDebuggerPrevious.d.ts.map +0 -1
  106. package/lib/test/types/validateDebuggerPrevious.js.map +0 -1
  107. package/tsconfig.esnext.json +0 -7
  108. /package/lib/{messageSchema.d.ts → messageSchema.d.mts} +0 -0
  109. /package/lib/{sanitize.d.ts → sanitize.d.mts} +0 -0
  110. /package/lib/{sanitize.d.ts.map → sanitize.d.mts.map} +0 -0
package/src/sanitizer.ts CHANGED
@@ -19,36 +19,36 @@
19
19
  */
20
20
 
21
21
  import * as Validator from "jsonschema";
22
- import { assert } from "@fluidframework/common-utils";
22
+ import { assert } from "@fluidframework/core-utils";
23
+ import { ISequencedDocumentMessage } from "@fluidframework/protocol-definitions";
23
24
  import {
24
- ISequencedDocumentMessage,
25
- } from "@fluidframework/protocol-definitions";
26
- import {
27
- attachContentsSchema,
28
- chunkedOpContentsSchema,
29
- joinContentsSchema,
30
- joinDataSchema,
31
- opContentsMapSchema,
32
- opContentsSchema,
33
- opContentsMergeTreeDeltaOpSchema,
34
- opContentsMergeTreeGroupOpSchema,
35
- opContentsRegisterCollectionSchema,
36
- proposeContentsSchema,
25
+ attachContentsSchema,
26
+ chunkedOpContentsSchema,
27
+ joinContentsSchema,
28
+ joinDataSchema,
29
+ opContentsMapSchema,
30
+ opContentsSchema,
31
+ opContentsMergeTreeDeltaOpSchema,
32
+ opContentsMergeTreeGroupOpSchema,
33
+ opContentsRegisterCollectionSchema,
34
+ proposeContentsSchema,
37
35
  } from "./messageSchema";
38
36
 
39
37
  enum TextType {
40
- Generic,
41
- Email,
42
- Name,
43
- FluidObject,
44
- MapKey,
38
+ Generic,
39
+ Email,
40
+ Name,
41
+ FluidObject,
42
+ MapKey,
45
43
  }
46
44
 
47
45
  // Workaround to jsonschema package not supporting "false" as a schema
48
46
  // that matches nothing
49
47
  const falseResult = {
50
- valid: false,
51
- toString: () => { return "Unmatched format"; },
48
+ valid: false,
49
+ toString: () => {
50
+ return "Unmatched format";
51
+ },
52
52
  };
53
53
 
54
54
  /**
@@ -58,637 +58,688 @@ const falseResult = {
58
58
  * size to the original message.
59
59
  */
60
60
  class ChunkedOpProcessor {
61
- /**
62
- * Message references so we can replace their contents in-place. These can
63
- * be top-level chunkedOp messages, or top-level op messages with a chunkedOp
64
- * within the contents
65
- */
66
- private messages = new Array<any>();
67
- /**
68
- * The messages' parsed contents for processing. Should parallel the
69
- * messages member
70
- */
71
- private parsedMessageContents = new Array<any>();
72
- private writtenBack = false;
73
- /**
74
- * keep track of the total starting length to make sure we don't somehow end
75
- * up with more content than we started with (meaning we may not be able to
76
- * write it back)
77
- */
78
- private concatenatedLength = 0;
79
-
80
- constructor(
81
- readonly validateSchemaFn: (object: any, schema: any) => boolean,
82
- readonly debug: boolean,
83
- ) { }
84
-
85
- debugMsg(msg: any) {
86
- if (this.debug) {
87
- console.error(msg);
88
- }
89
- }
90
-
91
- addMessage(message: any): void {
92
- this.messages.push(message);
93
-
94
- let parsed;
95
- try {
96
- parsed = JSON.parse(message.contents);
97
- if (message.type === "op") {
98
- // nested within a regular op
99
- // need to go deeper to get the desired contents
100
- parsed = parsed.contents;
101
- }
102
- } catch (e) {
103
- this.debugMsg(e);
104
- this.debugMsg(message.contents);
105
- }
106
- this.validateSchemaFn(parsed, chunkedOpContentsSchema);
107
- this.parsedMessageContents.push(parsed);
108
- }
109
-
110
- hasAllMessages(): boolean {
111
- const lastMsgContents = this.parsedMessageContents[this.parsedMessageContents.length - 1];
112
- return lastMsgContents.chunkId !== undefined && lastMsgContents.chunkId === lastMsgContents.totalChunks;
113
- }
114
-
115
- /**
116
- * @returns The concatenated contents of all the messages parsed as json
117
- */
118
- getConcatenatedContents(): any {
119
- const contentsString = this.parsedMessageContents.reduce((previousValue: string, currentValue: any) => {
120
- return previousValue + (currentValue.contents as string);
121
- }, "");
122
-
123
- this.concatenatedLength = contentsString.length;
124
- try {
125
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
126
- return JSON.parse(contentsString);
127
- } catch (e) {
128
- this.debugMsg(contentsString);
129
- this.debugMsg(e);
130
- return undefined;
131
- }
132
- }
133
-
134
- /**
135
- * Write back sanitized contents into the messages. The contents are
136
- * stringified, split up, and written in place to the messages that
137
- * were added earlier. The number of messages is preserved.
138
- * @param contents - Sanitized contents to write back
139
- */
140
- writeSanitizedContents(contents: any): void {
141
- // Write back a chunk size equal to the original
142
- const chunkSize = this.parsedMessageContents[0].contents.length;
143
-
144
- let stringified: string;
145
- try {
146
- stringified = JSON.stringify(contents);
147
- assert(stringified.length <= this.concatenatedLength,
148
- 0x089 /* "Stringified length of chunk contents > total starting length" */);
149
- } catch (e) {
150
- this.debugMsg(e);
151
- throw e;
152
- }
153
-
154
- for (let i = 0; i < this.messages.length; i++) {
155
- const substring = stringified.substring(i * chunkSize, (i + 1) * chunkSize);
156
-
157
- const parsedContents = this.parsedMessageContents[i];
158
- parsedContents.contents = substring;
159
- const message = this.messages[i];
160
-
161
- let stringifiedParsedContents;
162
- try {
163
- // for nested chunkedOps, we need to recreate the extra nesting layer
164
- // we removed earlier when adding the message
165
- if (message.type === "op") {
166
- const nestingLayer = {
167
- type: "chunkedOp",
168
- contents: parsedContents,
169
- };
170
- stringifiedParsedContents = JSON.stringify(nestingLayer);
171
- } else {
172
- stringifiedParsedContents = JSON.stringify(parsedContents);
173
- }
174
- } catch (e) {
175
- this.debugMsg(e);
176
- }
177
-
178
- message.contents = stringifiedParsedContents;
179
- }
180
-
181
- this.writtenBack = true;
182
- }
183
-
184
- reset(): void {
185
- assert(this.writtenBack, 0x08a /* "resetting ChunkedOpProcessor that never wrote back its contents" */);
186
- this.messages = new Array<any>();
187
- this.parsedMessageContents = new Array<any>();
188
- this.writtenBack = false;
189
- this.concatenatedLength = 0;
190
- }
191
-
192
- isPendingProcessing(): boolean {
193
- return this.messages.length !== 0;
194
- }
61
+ /**
62
+ * Message references so we can replace their contents in-place. These can
63
+ * be top-level chunkedOp messages, or top-level op messages with a chunkedOp
64
+ * within the contents
65
+ */
66
+ private messages = new Array<any>();
67
+ /**
68
+ * The messages' parsed contents for processing. Should parallel the
69
+ * messages member
70
+ */
71
+ private parsedMessageContents = new Array<any>();
72
+ private writtenBack = false;
73
+ /**
74
+ * keep track of the total starting length to make sure we don't somehow end
75
+ * up with more content than we started with (meaning we may not be able to
76
+ * write it back)
77
+ */
78
+ private concatenatedLength = 0;
79
+
80
+ constructor(
81
+ readonly validateSchemaFn: (object: any, schema: any) => boolean,
82
+ readonly debug: boolean,
83
+ ) {}
84
+
85
+ debugMsg(msg: any) {
86
+ if (this.debug) {
87
+ console.error(msg);
88
+ }
89
+ }
90
+
91
+ addMessage(message: any): void {
92
+ this.messages.push(message);
93
+
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(message.contents);
97
+ if (message.type === "op") {
98
+ // nested within a regular op
99
+ // need to go deeper to get the desired contents
100
+ parsed = parsed.contents;
101
+ }
102
+ } catch (e) {
103
+ this.debugMsg(e);
104
+ this.debugMsg(message.contents);
105
+ }
106
+ this.validateSchemaFn(parsed, chunkedOpContentsSchema);
107
+ this.parsedMessageContents.push(parsed);
108
+ }
109
+
110
+ hasAllMessages(): boolean {
111
+ const lastMsgContents = this.parsedMessageContents[this.parsedMessageContents.length - 1];
112
+ return (
113
+ lastMsgContents.chunkId !== undefined &&
114
+ lastMsgContents.chunkId === lastMsgContents.totalChunks
115
+ );
116
+ }
117
+
118
+ /**
119
+ * @returns The concatenated contents of all the messages parsed as json
120
+ */
121
+ getConcatenatedContents(): any {
122
+ const contentsString = this.parsedMessageContents.reduce(
123
+ (previousValue: string, currentValue: any) => {
124
+ return previousValue + (currentValue.contents as string);
125
+ },
126
+ "",
127
+ );
128
+
129
+ this.concatenatedLength = contentsString.length;
130
+ try {
131
+ return JSON.parse(contentsString);
132
+ } catch (e) {
133
+ this.debugMsg(contentsString);
134
+ this.debugMsg(e);
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Write back sanitized contents into the messages. The contents are
141
+ * stringified, split up, and written in place to the messages that
142
+ * were added earlier. The number of messages is preserved.
143
+ * @param contents - Sanitized contents to write back
144
+ */
145
+ writeSanitizedContents(contents: any): void {
146
+ // Write back a chunk size equal to the original
147
+ const chunkSize = this.parsedMessageContents[0].contents.length;
148
+
149
+ let stringified: string;
150
+ try {
151
+ stringified = JSON.stringify(contents);
152
+ assert(
153
+ stringified.length <= this.concatenatedLength,
154
+ 0x089 /* "Stringified length of chunk contents > total starting length" */,
155
+ );
156
+ } catch (e) {
157
+ this.debugMsg(e);
158
+ throw e;
159
+ }
160
+
161
+ for (let i = 0; i < this.messages.length; i++) {
162
+ const substring = stringified.substring(i * chunkSize, (i + 1) * chunkSize);
163
+
164
+ const parsedContents = this.parsedMessageContents[i];
165
+ parsedContents.contents = substring;
166
+ const message = this.messages[i];
167
+
168
+ let stringifiedParsedContents;
169
+ try {
170
+ // for nested chunkedOps, we need to recreate the extra nesting layer
171
+ // we removed earlier when adding the message
172
+ if (message.type === "op") {
173
+ const nestingLayer = {
174
+ type: "chunkedOp",
175
+ contents: parsedContents,
176
+ };
177
+ stringifiedParsedContents = JSON.stringify(nestingLayer);
178
+ } else {
179
+ stringifiedParsedContents = JSON.stringify(parsedContents);
180
+ }
181
+ } catch (e) {
182
+ this.debugMsg(e);
183
+ }
184
+
185
+ message.contents = stringifiedParsedContents;
186
+ }
187
+
188
+ this.writtenBack = true;
189
+ }
190
+
191
+ reset(): void {
192
+ assert(
193
+ this.writtenBack,
194
+ 0x08a /* "resetting ChunkedOpProcessor that never wrote back its contents" */,
195
+ );
196
+ this.messages = new Array<any>();
197
+ this.parsedMessageContents = new Array<any>();
198
+ this.writtenBack = false;
199
+ this.concatenatedLength = 0;
200
+ }
201
+
202
+ isPendingProcessing(): boolean {
203
+ return this.messages.length !== 0;
204
+ }
195
205
  }
196
206
 
197
207
  export class Sanitizer {
198
- readonly validator = new Validator.Validator();
199
- // Represents the keys used to store Fluid object identifiers, snapshot info,
200
- // and other string fields that should not be replaced in contents blobs to
201
- // ensure the messages are still usable
202
- readonly defaultExcludedKeys = new Set<string>();
203
- // Represents the keys used by merge-tree ops their "seg" property, where other
204
- // keys represent user information
205
- readonly mergeTreeExcludedKeys = new Set<string>();
206
- // Map of user information to what it was replaced with. Used to ensure the same
207
- // data have the same replacements
208
- readonly replacementMap = new Map<string, string>();
209
-
210
- /**
211
- * Validate that the provided message matches the provided schema.
212
- * For a full scrub, warn and continue (scrubber should fully sanitize unexpected
213
- * fields for ops), otherwise throw an error because we cannot be sure user
214
- * information is being sufficiently sanitized.
215
- */
216
- objectMatchesSchema = (object: any, schema: any): boolean => {
217
- const result = schema === false ? falseResult : this.validator.validate(object, schema);
218
- if (!result.valid) {
219
- const errorMsg = `Bad msg fmt:\n${result.toString()}\n${JSON.stringify(object, undefined, 2)}`;
220
-
221
- if (this.fullScrub || this.noBail) {
222
- this.debugMsg(errorMsg);
223
- } else {
224
- throw new Error(errorMsg);
225
- }
226
- }
227
- return result.valid;
228
- };
229
-
230
- readonly chunkProcessor = new ChunkedOpProcessor(this.objectMatchesSchema, this.debug);
231
-
232
- constructor(
233
- readonly messages: ISequencedDocumentMessage[],
234
- readonly fullScrub: boolean,
235
- readonly noBail: boolean,
236
- readonly debug: boolean = false,
237
- ) {
238
- this.defaultExcludedKeys.add("type");
239
- this.defaultExcludedKeys.add("id");
240
- this.defaultExcludedKeys.add("pkg");
241
- this.defaultExcludedKeys.add("snapshotFormatVersion");
242
- this.defaultExcludedKeys.add("packageVersion");
243
- this.mergeTreeExcludedKeys.add("nodeType");
244
- }
245
-
246
- debugMsg(msg: any) {
247
- if (this.debug) {
248
- console.error(msg);
249
- }
250
- }
251
-
252
- isFluidObjectKey(key: string): boolean {
253
- return key === "type" || key === "id";
254
- }
255
-
256
- getRandomText(len: number): string {
257
- let str = "";
258
- while (str.length < len) {
259
- str = str + Math.random().toString(36).substring(2);
260
- }
261
- return str.substr(0, len);
262
- }
263
-
264
- readonly wordTokenRegex = /\S+/g;
265
-
266
- readonly replaceRandomTextFn = (match: string): string => {
267
- if (this.replacementMap.has(match)) {
268
- return this.replacementMap.get(match)!;
269
- }
270
-
271
- const replacement = this.getRandomText(match.length);
272
- this.replacementMap.set(match, replacement);
273
- return replacement;
274
- };
275
-
276
- /**
277
- * Replace text with garbage. FluidObject types are not replaced when not under
278
- * full scrub mode. All other text is replaced consistently.
279
- */
280
- replaceText(input?: string, type: TextType = TextType.Generic): string | undefined {
281
- if (input === undefined) {
282
- return undefined;
283
- }
284
-
285
- if (type === TextType.FluidObject) {
286
- if (this.replacementMap.has(input)) {
287
- return this.replacementMap.get(input)!;
288
- }
289
-
290
- let replacement: string;
291
- if (this.fullScrub) {
292
- replacement = this.getRandomText(input.length);
293
- } else {
294
- replacement = input;
295
- }
296
-
297
- this.replacementMap.set(input, replacement);
298
- return replacement;
299
- }
300
-
301
- return input.replace(this.wordTokenRegex, this.replaceRandomTextFn);
302
- }
303
-
304
- replaceArray(input: any[]): any[] {
305
- for (let i = 0; i < input.length; i++) {
306
- const value = input[i];
307
- if (typeof value === "string") {
308
- input[i] = this.replaceText(value);
309
- } else if (Array.isArray(value)) {
310
- input[i] = this.replaceArray(value);
311
- } else if (typeof value === "object") {
312
- input[i] = this.replaceObject(value);
313
- }
314
- }
315
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
316
- return input;
317
- }
318
-
319
- /**
320
- * (sort of) recurses down the values of a JSON object to sanitize all its strings
321
- * (only checks strings, arrays, and objects)
322
- * @param input - The object to sanitize
323
- * @param excludedKeys - object keys for which to skip replacement when not in fullScrub
324
- */
325
- // eslint-disable-next-line @typescript-eslint/ban-types
326
- replaceObject(input: object | null, excludedKeys: Set<string> = this.defaultExcludedKeys): object | null {
327
- // File might contain actual nulls
328
- if (input === null || input === undefined) {
329
- return input;
330
- }
331
-
332
- const keys = Object.keys(input);
333
- keys.forEach((key) => {
334
- if (this.fullScrub || !excludedKeys.has(key)) {
335
- const value = input[key];
336
- if (typeof value === "string") {
337
- input[key] = this.replaceText(
338
- value,
339
- this.isFluidObjectKey(key) ? TextType.FluidObject : TextType.Generic,
340
- );
341
- } else if (Array.isArray(value)) {
342
- input[key] = this.replaceArray(value);
343
- } else if (typeof value === "object") {
344
- input[key] = this.replaceObject(value, excludedKeys);
345
- }
346
- }
347
- });
348
- return input;
349
- }
350
-
351
- /**
352
- * Replacement on an unknown type or a parsed root level object
353
- * without a key
354
- * @param input - The object to sanitize
355
- * @param excludedKeys - object keys for which to skip replacement when not in fullScrub
356
- */
357
- replaceAny(input: any, excludedKeys: Set<string> = this.defaultExcludedKeys): any {
358
- if (input === null || input === undefined) {
359
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
360
- return input;
361
- }
362
-
363
- if (typeof input === "string") {
364
- return this.replaceText(input);
365
- } else if (Array.isArray(input)) {
366
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
367
- return this.replaceArray(input);
368
- } else if (typeof input === "object") {
369
- return this.replaceObject(input, excludedKeys);
370
- }
371
-
372
- // Don't run replacement on any other types
373
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
374
- return input;
375
- }
376
-
377
- fixJoin(message: any) {
378
- if (!this.objectMatchesSchema(message.contents, joinContentsSchema)) {
379
- message.contents = this.replaceAny(message.contents);
380
- }
381
-
382
- try {
383
- let data = JSON.parse(message.data);
384
- if (!this.objectMatchesSchema(data, joinDataSchema)) {
385
- data = this.replaceAny(data);
386
- } else {
387
- const user = data.detail.user;
388
- user.id = this.replaceText(user.id, TextType.Email);
389
- user.email = this.replaceText(user.email, TextType.Email);
390
- user.name = this.replaceText(user.name, TextType.Name);
391
- }
392
-
393
- message.data = JSON.stringify(data);
394
- } catch (e) {
395
- this.debugMsg(e);
396
- }
397
- }
398
-
399
- fixPropose(message: any) {
400
- if (!this.objectMatchesSchema(message.contents, proposeContentsSchema)) {
401
- message.contents = this.replaceAny(message.contents);
402
- } else {
403
- if (typeof message.contents === "string") {
404
- try {
405
- const data = JSON.parse(message.contents);
406
- if (this.fullScrub) {
407
- const pkg = data.value?.package;
408
- if (pkg?.name) {
409
- pkg.name = this.replaceText(pkg.name, TextType.FluidObject);
410
- }
411
- if (Array.isArray(pkg?.fluid?.browser?.umd?.files)) {
412
- pkg.fluid.browser.umd.files = this.replaceArray(pkg.fluid.browser.umd.files);
413
- }
414
- }
415
- } catch (e) {
416
- this.debugMsg(e);
417
- }
418
- } else {
419
- if (this.fullScrub) {
420
- message.contents.value = this.replaceText(message.contents.value, TextType.FluidObject);
421
- }
422
- }
423
- }
424
- }
425
-
426
- fixAttachEntries(entries: any[]) {
427
- entries.forEach((element) => {
428
- // Tree type
429
- if (element.value.entries) {
430
- this.fixAttachEntries(element.value.entries);
431
- } else {
432
- // Blob (leaf) type
433
- try {
434
- if (typeof element.value.contents === "string") {
435
- let data = JSON.parse(element.value.contents);
436
- data = this.replaceObject(data);
437
- element.value.contents = JSON.stringify(data);
438
- }
439
- } catch (e) {
440
- this.debugMsg(e);
441
- }
442
- }
443
- });
444
- }
445
-
446
- /**
447
- * Fix the content of an attach in place
448
- * @param contents - contents object to fix
449
- */
450
- fixAttachContents(contents: any): any {
451
- assert(typeof contents === "object", 0x08b /* "Unexpected type on contents for fix of an attach!" */);
452
- if (!this.objectMatchesSchema(contents, attachContentsSchema)) {
453
- this.replaceObject(contents);
454
- } else {
455
- if (this.fullScrub) {
456
- contents.id = this.replaceText(contents.id, TextType.FluidObject);
457
- contents.type = this.replaceText(contents.type, TextType.FluidObject);
458
- }
459
-
460
- this.fixAttachEntries(contents.snapshot.entries);
461
- }
462
- }
463
-
464
- /**
465
- * Fix an attach message at the root level or a ContainerMessageType attach. Attach
466
- * messages found within an op message should instead have their contents parsed out
467
- * and sent to fixAttachContents.
468
- * @param message - The attach message to fix
469
- * @param withinOp - If the message is from within an op message (as opposed to being
470
- * an attach message at the root level). Root level attach messages have "snapshot"
471
- * under a "contents" key, whereas attach messages from within an op message have it
472
- * under a "content" key
473
- */
474
- fixAttach(message: any) {
475
- // Handle case where contents is stringified json
476
- if (typeof message.contents === "string") {
477
- try {
478
- const data = JSON.parse(message.contents);
479
- this.fixAttachContents(data);
480
- message.contents = JSON.stringify(data);
481
- } catch (e) {
482
- this.debugMsg(e);
483
- return;
484
- }
485
- } else {
486
- this.fixAttachContents(message.contents);
487
- }
488
- }
489
-
490
- fixDeltaOp(deltaOp: any) {
491
- if (typeof deltaOp.seg === "string") {
492
- deltaOp.seg = this.replaceText(deltaOp.seg);
493
- } else {
494
- deltaOp.seg = this.replaceObject(deltaOp.seg, this.mergeTreeExcludedKeys);
495
- }
496
- }
497
-
498
- /**
499
- * Fix the contents object for an op message. Does not do extra type handling. Does
500
- * not handle special container message types like "attach", "component", and
501
- * "chunkedOp" (these should be handled by the caller)
502
- * @param contents - The contents object for an op message. If it was a string in the
503
- * message, it must have been converted to an object first
504
- */
505
- fixOpContentsObject(contents: any) {
506
- // do replacement
507
- if (!this.objectMatchesSchema(contents, opContentsSchema)) {
508
- this.replaceAny(contents);
509
- } else {
510
- if (this.fullScrub) {
511
- contents.address = this.replaceText(contents.address, TextType.FluidObject);
512
- }
513
-
514
- const innerContent = contents.contents.content;
515
- assert(innerContent !== undefined, 0x08c /* "innerContent for fixing op contents is undefined!" */);
516
- if (contents.contents.type === "attach") {
517
- // attach op
518
- // handle case where inner content is stringified json
519
- if (typeof contents.contents.content === "string") {
520
- try {
521
- const data = JSON.parse(contents.contents.content);
522
- this.fixAttachContents(data);
523
- contents.contents.content = JSON.stringify(data);
524
- } catch (e) {
525
- this.debugMsg(e);
526
- }
527
- } else {
528
- this.fixAttachContents(contents.contents.content);
529
- }
530
- } else if (this.validator.validate(innerContent, opContentsMapSchema).valid) {
531
- // map op
532
- if (this.fullScrub) {
533
- innerContent.address = this.replaceText(innerContent.address, TextType.FluidObject);
534
- innerContent.contents.key = this.replaceText(innerContent.contents.key, TextType.MapKey);
535
- }
536
- if (innerContent.contents.value !== undefined) {
537
- innerContent.contents.value.value = this.replaceAny(innerContent.contents.value.value);
538
- }
539
- } else if (this.validator.validate(innerContent, opContentsMergeTreeGroupOpSchema).valid) {
540
- // merge tree group op
541
- if (this.fullScrub) {
542
- innerContent.address = this.replaceText(innerContent.address, TextType.FluidObject);
543
- }
544
- innerContent.contents.ops.forEach((deltaOp) => {
545
- this.fixDeltaOp(deltaOp);
546
- });
547
- } else if (this.validator.validate(innerContent, opContentsMergeTreeDeltaOpSchema).valid) {
548
- // merge tree delta op
549
- if (this.fullScrub) {
550
- innerContent.address = this.replaceText(innerContent.address, TextType.FluidObject);
551
- }
552
- this.fixDeltaOp(innerContent.contents);
553
- } else if (this.validator.validate(innerContent, opContentsRegisterCollectionSchema).valid) {
554
- // register collection op
555
- if (this.fullScrub) {
556
- innerContent.address = this.replaceText(innerContent.address, TextType.FluidObject);
557
- innerContent.contents.key = this.replaceText(innerContent.contents.key, TextType.MapKey);
558
- }
559
- if (innerContent.contents.value !== undefined) {
560
- innerContent.contents.value.value = this.replaceAny(innerContent.contents.value.value);
561
- }
562
- } else {
563
- // message contents don't match any known op format
564
- this.objectMatchesSchema(contents, false);
565
- }
566
- }
567
- }
568
-
569
- fixOp(message: any) {
570
- // handle case where contents is stringified json
571
- let msgContents;
572
- if (typeof message.contents === "string") {
573
- try {
574
- msgContents = JSON.parse(message.contents);
575
- } catch (e) {
576
- this.debugMsg(e);
577
- return;
578
- }
579
- } else {
580
- msgContents = message.contents;
581
- }
582
-
583
- // handle container message types
584
- switch (msgContents.type) {
585
- case "attach": {
586
- // this one is like a regular attach op, except its contents aren't nested as deep
587
- // run fixAttach directly and return
588
- this.fixAttach(msgContents);
589
- break;
590
- }
591
- case "component": {
592
- // this one functionally nests its contents one layer deeper
593
- // bring up the contents object and continue as usual
594
- this.fixOpContentsObject(msgContents.contents);
595
- break;
596
- }
597
- case "chunkedOp": {
598
- // this is a (regular?) op split into multiple parts due to size, e.g. because it
599
- // has an attached image, and where the chunkedOp is within the top-level op's contents
600
- // (as opposed to being at the top-level). The contents of the chunks need to be
601
- // concatenated to form the complete stringified json object
602
- // Early return here to skip re-stringify because no changes are made until the last
603
- // chunk, and the ChunkedOpProcessor will handle everything at that point
604
- return this.fixChunkedOp(message);
605
- }
606
- case "blobAttach": {
607
- // TODO: handle this properly once blob api is used
608
- this.debugMsg("TODO: blobAttach ops are skipped/unhandled");
609
- return;
610
- }
611
- default: {
612
- // A regular op
613
- this.fixOpContentsObject(msgContents);
614
- }
615
- }
616
-
617
- // re-stringify the json if needed
618
- if (typeof message.contents === "string") {
619
- try {
620
- message.contents = JSON.stringify(msgContents);
621
- } catch (e) {
622
- this.debugMsg(e);
623
- return;
624
- }
625
- }
626
- }
627
-
628
- /**
629
- * @param message - The top-level chunkedOp message or a top-level op message
630
- * with a chunkedOp inside its contents
631
- */
632
- fixChunkedOp(message: any) {
633
- this.chunkProcessor.addMessage(message);
634
- if (!this.chunkProcessor.hasAllMessages()) {
635
- return;
636
- }
637
-
638
- const contents = this.chunkProcessor.getConcatenatedContents();
639
- this.fixOpContentsObject(contents);
640
-
641
- this.chunkProcessor.writeSanitizedContents(contents);
642
- this.chunkProcessor.reset();
643
- }
644
-
645
- sanitize(): ISequencedDocumentMessage[] {
646
- let seq = 0;
647
-
648
- try {
649
- this.messages.map((message) => {
650
- seq = message.sequenceNumber;
651
- // message types from protocol-definitions' protocol.ts
652
- switch (message.type) {
653
- case "join": {
654
- this.fixJoin(message);
655
- break;
656
- }
657
- case "propose": {
658
- this.fixPropose(message);
659
- break;
660
- }
661
- case "attach": {
662
- this.fixAttach(message);
663
- break;
664
- }
665
- case "op": {
666
- this.fixOp(message);
667
- break;
668
- }
669
- case "chunkedOp": {
670
- this.fixChunkedOp(message);
671
- break;
672
- }
673
- case "noop":
674
- case "leave":
675
- case "noClient":
676
- case "summarize":
677
- case "summaryAck":
678
- case "summaryNack":
679
- break;
680
- default:
681
- this.debugMsg(`Unexpected op type ${message.type}`);
682
- }
683
- });
684
-
685
- // make sure we don't miss an incomplete chunked op at the end
686
- assert(!this.chunkProcessor.isPendingProcessing(), 0x08d /* "After sanitize, pending incomplete ops!" */);
687
- } catch (error) {
688
- this.debugMsg(`Error while processing sequenceNumber ${seq}`);
689
- throw error;
690
- }
691
-
692
- return this.messages;
693
- }
208
+ readonly validator = new Validator.Validator();
209
+ // Represents the keys used to store Fluid object identifiers, snapshot info,
210
+ // and other string fields that should not be replaced in contents blobs to
211
+ // ensure the messages are still usable
212
+ readonly defaultExcludedKeys = new Set<string>();
213
+ // Represents the keys used by merge-tree ops their "seg" property, where other
214
+ // keys represent user information
215
+ readonly mergeTreeExcludedKeys = new Set<string>();
216
+ // Map of user information to what it was replaced with. Used to ensure the same
217
+ // data have the same replacements
218
+ readonly replacementMap = new Map<string, string>();
219
+
220
+ /**
221
+ * Validate that the provided message matches the provided schema.
222
+ * For a full scrub, warn and continue (scrubber should fully sanitize unexpected
223
+ * fields for ops), otherwise throw an error because we cannot be sure user
224
+ * information is being sufficiently sanitized.
225
+ */
226
+ objectMatchesSchema = (object: any, schema: any): boolean => {
227
+ const result = schema === false ? falseResult : this.validator.validate(object, schema);
228
+ if (!result.valid) {
229
+ const errorMsg = `Bad msg fmt:\n${result.toString()}\n${JSON.stringify(
230
+ object,
231
+ undefined,
232
+ 2,
233
+ )}`;
234
+
235
+ if (this.fullScrub || this.noBail) {
236
+ this.debugMsg(errorMsg);
237
+ } else {
238
+ throw new Error(errorMsg);
239
+ }
240
+ }
241
+ return result.valid;
242
+ };
243
+
244
+ readonly chunkProcessor = new ChunkedOpProcessor(this.objectMatchesSchema, this.debug);
245
+
246
+ constructor(
247
+ readonly messages: ISequencedDocumentMessage[],
248
+ readonly fullScrub: boolean,
249
+ readonly noBail: boolean,
250
+ readonly debug: boolean = false,
251
+ ) {
252
+ this.defaultExcludedKeys.add("type");
253
+ this.defaultExcludedKeys.add("id");
254
+ this.defaultExcludedKeys.add("pkg");
255
+ this.defaultExcludedKeys.add("snapshotFormatVersion");
256
+ this.defaultExcludedKeys.add("packageVersion");
257
+ this.mergeTreeExcludedKeys.add("nodeType");
258
+ }
259
+
260
+ debugMsg(msg: any) {
261
+ if (this.debug) {
262
+ console.error(msg);
263
+ }
264
+ }
265
+
266
+ isFluidObjectKey(key: string): boolean {
267
+ return key === "type" || key === "id";
268
+ }
269
+
270
+ getRandomText(len: number): string {
271
+ let str = "";
272
+ while (str.length < len) {
273
+ str = str + Math.random().toString(36).substring(2);
274
+ }
275
+ return str.substr(0, len);
276
+ }
277
+
278
+ readonly wordTokenRegex = /\S+/g;
279
+
280
+ readonly replaceRandomTextFn = (match: string): string => {
281
+ if (this.replacementMap.has(match)) {
282
+ return this.replacementMap.get(match)!;
283
+ }
284
+
285
+ const replacement = this.getRandomText(match.length);
286
+ this.replacementMap.set(match, replacement);
287
+ return replacement;
288
+ };
289
+
290
+ /**
291
+ * Replace text with garbage. FluidObject types are not replaced when not under
292
+ * full scrub mode. All other text is replaced consistently.
293
+ */
294
+ replaceText(input?: string, type: TextType = TextType.Generic): string | undefined {
295
+ if (input === undefined) {
296
+ return undefined;
297
+ }
298
+
299
+ if (type === TextType.FluidObject) {
300
+ if (this.replacementMap.has(input)) {
301
+ return this.replacementMap.get(input)!;
302
+ }
303
+
304
+ const replacement = this.fullScrub ? this.getRandomText(input.length) : input;
305
+
306
+ this.replacementMap.set(input, replacement);
307
+ return replacement;
308
+ }
309
+
310
+ return input.replace(this.wordTokenRegex, this.replaceRandomTextFn);
311
+ }
312
+
313
+ replaceArray(input: any[]): any[] {
314
+ for (let i = 0; i < input.length; i++) {
315
+ const value = input[i];
316
+ if (typeof value === "string") {
317
+ input[i] = this.replaceText(value);
318
+ } else if (Array.isArray(value)) {
319
+ input[i] = this.replaceArray(value);
320
+ } else if (typeof value === "object") {
321
+ input[i] = this.replaceObject(value);
322
+ }
323
+ }
324
+ return input;
325
+ }
326
+
327
+ /**
328
+ * (sort of) recurses down the values of a JSON object to sanitize all its strings
329
+ * (only checks strings, arrays, and objects)
330
+ * @param input - The object to sanitize
331
+ * @param excludedKeys - object keys for which to skip replacement when not in fullScrub
332
+ */
333
+ replaceObject(
334
+ // eslint-disable-next-line @rushstack/no-new-null
335
+ input: object | null,
336
+ excludedKeys: Set<string> = this.defaultExcludedKeys,
337
+ // eslint-disable-next-line @rushstack/no-new-null
338
+ ): object | null {
339
+ // File might contain actual nulls
340
+ if (input === null || input === undefined) {
341
+ return input;
342
+ }
343
+
344
+ const keys = Object.keys(input);
345
+ keys.forEach((key) => {
346
+ if (this.fullScrub || !excludedKeys.has(key)) {
347
+ const value = input[key];
348
+ if (typeof value === "string") {
349
+ input[key] = this.replaceText(
350
+ value,
351
+ this.isFluidObjectKey(key) ? TextType.FluidObject : TextType.Generic,
352
+ );
353
+ } else if (Array.isArray(value)) {
354
+ input[key] = this.replaceArray(value);
355
+ } else if (typeof value === "object") {
356
+ input[key] = this.replaceObject(value, excludedKeys);
357
+ }
358
+ }
359
+ });
360
+ return input;
361
+ }
362
+
363
+ /**
364
+ * Replacement on an unknown type or a parsed root level object
365
+ * without a key
366
+ * @param input - The object to sanitize
367
+ * @param excludedKeys - object keys for which to skip replacement when not in fullScrub
368
+ */
369
+ replaceAny(input: any, excludedKeys: Set<string> = this.defaultExcludedKeys): any {
370
+ if (input === null || input === undefined) {
371
+ return input;
372
+ }
373
+
374
+ if (typeof input === "string") {
375
+ return this.replaceText(input);
376
+ } else if (Array.isArray(input)) {
377
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
378
+ return this.replaceArray(input);
379
+ } else if (typeof input === "object") {
380
+ return this.replaceObject(input, excludedKeys);
381
+ }
382
+
383
+ // Don't run replacement on any other types
384
+ return input;
385
+ }
386
+
387
+ fixJoin(message: any) {
388
+ if (!this.objectMatchesSchema(message.contents, joinContentsSchema)) {
389
+ message.contents = this.replaceAny(message.contents);
390
+ }
391
+
392
+ try {
393
+ let data = JSON.parse(message.data);
394
+ if (!this.objectMatchesSchema(data, joinDataSchema)) {
395
+ data = this.replaceAny(data);
396
+ } else {
397
+ const user = data.detail.user;
398
+ user.id = this.replaceText(user.id, TextType.Email);
399
+ user.email = this.replaceText(user.email, TextType.Email);
400
+ user.name = this.replaceText(user.name, TextType.Name);
401
+ }
402
+
403
+ message.data = JSON.stringify(data);
404
+ } catch (e) {
405
+ this.debugMsg(e);
406
+ }
407
+ }
408
+
409
+ fixPropose(message: any) {
410
+ if (!this.objectMatchesSchema(message.contents, proposeContentsSchema)) {
411
+ message.contents = this.replaceAny(message.contents);
412
+ } else {
413
+ if (typeof message.contents === "string") {
414
+ try {
415
+ const data = JSON.parse(message.contents);
416
+ if (this.fullScrub) {
417
+ const pkg = data.value?.package;
418
+ if (pkg?.name) {
419
+ pkg.name = this.replaceText(pkg.name, TextType.FluidObject);
420
+ }
421
+ if (Array.isArray(pkg?.fluid?.browser?.umd?.files)) {
422
+ pkg.fluid.browser.umd.files = this.replaceArray(
423
+ pkg.fluid.browser.umd.files,
424
+ );
425
+ }
426
+ }
427
+ } catch (e) {
428
+ this.debugMsg(e);
429
+ }
430
+ } else {
431
+ if (this.fullScrub) {
432
+ message.contents.value = this.replaceText(
433
+ message.contents.value,
434
+ TextType.FluidObject,
435
+ );
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ fixAttachEntries(entries: any[]) {
442
+ entries.forEach((element) => {
443
+ // Tree type
444
+ if (element.value.entries) {
445
+ this.fixAttachEntries(element.value.entries);
446
+ } else {
447
+ // Blob (leaf) type
448
+ try {
449
+ if (typeof element.value.contents === "string") {
450
+ let data = JSON.parse(element.value.contents);
451
+ data = this.replaceObject(data);
452
+ element.value.contents = JSON.stringify(data);
453
+ }
454
+ } catch (e) {
455
+ this.debugMsg(e);
456
+ }
457
+ }
458
+ });
459
+ }
460
+
461
+ /**
462
+ * Fix the content of an attach in place
463
+ * @param contents - contents object to fix
464
+ */
465
+ fixAttachContents(contents: any): any {
466
+ assert(
467
+ typeof contents === "object",
468
+ 0x08b /* "Unexpected type on contents for fix of an attach!" */,
469
+ );
470
+ if (!this.objectMatchesSchema(contents, attachContentsSchema)) {
471
+ this.replaceObject(contents);
472
+ } else {
473
+ if (this.fullScrub) {
474
+ contents.id = this.replaceText(contents.id, TextType.FluidObject);
475
+ contents.type = this.replaceText(contents.type, TextType.FluidObject);
476
+ }
477
+
478
+ this.fixAttachEntries(contents.snapshot.entries);
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Fix an attach message at the root level or a ContainerMessageType attach. Attach
484
+ * messages found within an op message should instead have their contents parsed out
485
+ * and sent to fixAttachContents.
486
+ * @param message - The attach message to fix
487
+ * @param withinOp - If the message is from within an op message (as opposed to being
488
+ * an attach message at the root level). Root level attach messages have "snapshot"
489
+ * under a "contents" key, whereas attach messages from within an op message have it
490
+ * under a "content" key
491
+ */
492
+ fixAttach(message: any) {
493
+ // Handle case where contents is stringified json
494
+ if (typeof message.contents === "string") {
495
+ try {
496
+ const data = JSON.parse(message.contents);
497
+ this.fixAttachContents(data);
498
+ message.contents = JSON.stringify(data);
499
+ } catch (e) {
500
+ this.debugMsg(e);
501
+ return;
502
+ }
503
+ } else {
504
+ this.fixAttachContents(message.contents);
505
+ }
506
+ }
507
+
508
+ fixDeltaOp(deltaOp: any) {
509
+ deltaOp.seg =
510
+ typeof deltaOp.seg === "string"
511
+ ? this.replaceText(deltaOp.seg)
512
+ : this.replaceObject(deltaOp.seg, this.mergeTreeExcludedKeys);
513
+ }
514
+
515
+ /**
516
+ * Fix the contents object for an op message. Does not do extra type handling. Does
517
+ * not handle special container message types like "attach", "component", and
518
+ * "chunkedOp" (these should be handled by the caller)
519
+ * @param contents - The contents object for an op message. If it was a string in the
520
+ * message, it must have been converted to an object first
521
+ */
522
+ fixOpContentsObject(contents: any) {
523
+ // do replacement
524
+ if (!this.objectMatchesSchema(contents, opContentsSchema)) {
525
+ this.replaceAny(contents);
526
+ } else {
527
+ if (this.fullScrub) {
528
+ contents.address = this.replaceText(contents.address, TextType.FluidObject);
529
+ }
530
+
531
+ const innerContent = contents.contents.content;
532
+ assert(
533
+ innerContent !== undefined,
534
+ 0x08c /* "innerContent for fixing op contents is undefined!" */,
535
+ );
536
+ if (contents.contents.type === "attach") {
537
+ // attach op
538
+ // handle case where inner content is stringified json
539
+ if (typeof contents.contents.content === "string") {
540
+ try {
541
+ const data = JSON.parse(contents.contents.content);
542
+ this.fixAttachContents(data);
543
+ contents.contents.content = JSON.stringify(data);
544
+ } catch (e) {
545
+ this.debugMsg(e);
546
+ }
547
+ } else {
548
+ this.fixAttachContents(contents.contents.content);
549
+ }
550
+ } else if (this.validator.validate(innerContent, opContentsMapSchema).valid) {
551
+ // map op
552
+ if (this.fullScrub) {
553
+ innerContent.address = this.replaceText(
554
+ innerContent.address,
555
+ TextType.FluidObject,
556
+ );
557
+ innerContent.contents.key = this.replaceText(
558
+ innerContent.contents.key,
559
+ TextType.MapKey,
560
+ );
561
+ }
562
+ if (innerContent.contents.value !== undefined) {
563
+ innerContent.contents.value.value = this.replaceAny(
564
+ innerContent.contents.value.value,
565
+ );
566
+ }
567
+ } else if (
568
+ this.validator.validate(innerContent, opContentsMergeTreeGroupOpSchema).valid
569
+ ) {
570
+ // merge tree group op
571
+ if (this.fullScrub) {
572
+ innerContent.address = this.replaceText(
573
+ innerContent.address,
574
+ TextType.FluidObject,
575
+ );
576
+ }
577
+ innerContent.contents.ops.forEach((deltaOp) => {
578
+ this.fixDeltaOp(deltaOp);
579
+ });
580
+ } else if (
581
+ this.validator.validate(innerContent, opContentsMergeTreeDeltaOpSchema).valid
582
+ ) {
583
+ // merge tree delta op
584
+ if (this.fullScrub) {
585
+ innerContent.address = this.replaceText(
586
+ innerContent.address,
587
+ TextType.FluidObject,
588
+ );
589
+ }
590
+ this.fixDeltaOp(innerContent.contents);
591
+ } else if (
592
+ this.validator.validate(innerContent, opContentsRegisterCollectionSchema).valid
593
+ ) {
594
+ // register collection op
595
+ if (this.fullScrub) {
596
+ innerContent.address = this.replaceText(
597
+ innerContent.address,
598
+ TextType.FluidObject,
599
+ );
600
+ innerContent.contents.key = this.replaceText(
601
+ innerContent.contents.key,
602
+ TextType.MapKey,
603
+ );
604
+ }
605
+ if (innerContent.contents.value !== undefined) {
606
+ innerContent.contents.value.value = this.replaceAny(
607
+ innerContent.contents.value.value,
608
+ );
609
+ }
610
+ } else {
611
+ // message contents don't match any known op format
612
+ this.objectMatchesSchema(contents, false);
613
+ }
614
+ }
615
+ }
616
+
617
+ fixOp(message: any) {
618
+ // handle case where contents is stringified json
619
+ let msgContents;
620
+ if (typeof message.contents === "string") {
621
+ try {
622
+ msgContents = JSON.parse(message.contents);
623
+ } catch (e) {
624
+ this.debugMsg(e);
625
+ return;
626
+ }
627
+ } else {
628
+ msgContents = message.contents;
629
+ }
630
+
631
+ // handle container message types
632
+ switch (msgContents.type) {
633
+ case "attach": {
634
+ // this one is like a regular attach op, except its contents aren't nested as deep
635
+ // run fixAttach directly and return
636
+ this.fixAttach(msgContents);
637
+ break;
638
+ }
639
+ case "component": {
640
+ // this one functionally nests its contents one layer deeper
641
+ // bring up the contents object and continue as usual
642
+ this.fixOpContentsObject(msgContents.contents);
643
+ break;
644
+ }
645
+ case "chunkedOp": {
646
+ // this is a (regular?) op split into multiple parts due to size, e.g. because it
647
+ // has an attached image, and where the chunkedOp is within the top-level op's contents
648
+ // (as opposed to being at the top-level). The contents of the chunks need to be
649
+ // concatenated to form the complete stringified json object
650
+ // Early return here to skip re-stringify because no changes are made until the last
651
+ // chunk, and the ChunkedOpProcessor will handle everything at that point
652
+ return this.fixChunkedOp(message);
653
+ }
654
+ case "blobAttach": {
655
+ // TODO: handle this properly once blob api is used
656
+ this.debugMsg("TODO: blobAttach ops are skipped/unhandled");
657
+ return;
658
+ }
659
+ default: {
660
+ // A regular op
661
+ this.fixOpContentsObject(msgContents);
662
+ }
663
+ }
664
+
665
+ // re-stringify the json if needed
666
+ if (typeof message.contents === "string") {
667
+ try {
668
+ message.contents = JSON.stringify(msgContents);
669
+ } catch (e) {
670
+ this.debugMsg(e);
671
+ return;
672
+ }
673
+ }
674
+ }
675
+
676
+ /**
677
+ * @param message - The top-level chunkedOp message or a top-level op message
678
+ * with a chunkedOp inside its contents
679
+ */
680
+ fixChunkedOp(message: any) {
681
+ this.chunkProcessor.addMessage(message);
682
+ if (!this.chunkProcessor.hasAllMessages()) {
683
+ return;
684
+ }
685
+
686
+ const contents = this.chunkProcessor.getConcatenatedContents();
687
+ this.fixOpContentsObject(contents);
688
+
689
+ this.chunkProcessor.writeSanitizedContents(contents);
690
+ this.chunkProcessor.reset();
691
+ }
692
+
693
+ sanitize(): ISequencedDocumentMessage[] {
694
+ let seq = 0;
695
+
696
+ try {
697
+ this.messages.map((message) => {
698
+ seq = message.sequenceNumber;
699
+ // message types from protocol-definitions' protocol.ts
700
+ switch (message.type) {
701
+ case "join": {
702
+ this.fixJoin(message);
703
+ break;
704
+ }
705
+ case "propose": {
706
+ this.fixPropose(message);
707
+ break;
708
+ }
709
+ case "attach": {
710
+ this.fixAttach(message);
711
+ break;
712
+ }
713
+ case "op": {
714
+ this.fixOp(message);
715
+ break;
716
+ }
717
+ case "chunkedOp": {
718
+ this.fixChunkedOp(message);
719
+ break;
720
+ }
721
+ case "noop":
722
+ case "leave":
723
+ case "noClient":
724
+ case "summarize":
725
+ case "summaryAck":
726
+ case "summaryNack":
727
+ break;
728
+ default:
729
+ this.debugMsg(`Unexpected op type ${message.type}`);
730
+ }
731
+ });
732
+
733
+ // make sure we don't miss an incomplete chunked op at the end
734
+ assert(
735
+ !this.chunkProcessor.isPendingProcessing(),
736
+ 0x08d /* "After sanitize, pending incomplete ops!" */,
737
+ );
738
+ } catch (error) {
739
+ this.debugMsg(`Error while processing sequenceNumber ${seq}`);
740
+ throw error;
741
+ }
742
+
743
+ return this.messages;
744
+ }
694
745
  }