check_chunk.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import commandLineArgs from 'command-line-args'
  2. import {
  3. loadAtVersion,
  4. getProjectChunksFromVersion,
  5. } from '../lib/chunk_store/index.js'
  6. import { client } from '../lib/mongodb.js'
  7. import knex from '../lib/knex.js'
  8. import redis from '../lib/redis.js'
  9. import { loadGlobalBlobs, BlobStore } from '../lib/blob_store/index.js'
  10. import { getContentHash } from '../lib/content_hash.js'
  11. import core from 'overleaf-editor-core'
  12. import Events from 'node:events'
  13. Events.setMaxListeners(20)
  14. const { StringFileData, LazyStringFileData } = core
  15. const optionDefinitions = [
  16. { name: 'historyId', alias: 'p', type: String },
  17. { name: 'version', alias: 'v', type: Number },
  18. { name: 'persistedOnly', alias: 'o', type: Boolean },
  19. ]
  20. async function ensureFileLoaded(file, blobStore, currentVersion, path) {
  21. if (
  22. typeof file.load === 'function' &&
  23. file.data instanceof LazyStringFileData
  24. ) {
  25. if (file.data.rangesHash) {
  26. console.log(
  27. `Loading rangesHash ${file.data.rangesHash} for ${path} at version ${currentVersion}`
  28. )
  29. } else {
  30. console.log(
  31. `No rangesHash found for ${path} at version ${currentVersion}`
  32. )
  33. }
  34. await file.load('eager', blobStore)
  35. console.log('=> file', file.toRaw())
  36. }
  37. }
  38. function checkFileTrackedChanges(path, file, currentVersion) {
  39. let violations = false
  40. const positions = []
  41. if (!(file.data instanceof StringFileData)) {
  42. return { violations, positions }
  43. }
  44. let tcList
  45. try {
  46. tcList = file.getTrackedChanges()
  47. } catch (err) {
  48. return { violations, positions }
  49. }
  50. if (!tcList) return { violations, positions }
  51. const changesArr = Array.from(tcList)
  52. let prevTc = null
  53. for (const tc of changesArr) {
  54. positions.push(`(${tc.range.start}, ${tc.range.end})`)
  55. if (prevTc) {
  56. if (prevTc.range.start > tc.range.start) {
  57. console.error(
  58. `VIOLATION: Unsorted ranges in ${path} at version ${currentVersion}: [${prevTc.range.start}, ${prevTc.range.end}] comes before [${tc.range.start}, ${tc.range.end}]`
  59. )
  60. violations = true
  61. }
  62. if (prevTc.range.overlaps(tc.range)) {
  63. console.error(
  64. `VIOLATION: Overlapping ranges in ${path} at version ${currentVersion}: [${prevTc.range.start}, ${prevTc.range.end}] overlaps [${tc.range.start}, ${tc.range.end}]`
  65. )
  66. violations = true
  67. }
  68. }
  69. prevTc = tc
  70. }
  71. return { violations, positions }
  72. }
  73. async function checkSnapshot(snapshot, blobStore, currentVersion) {
  74. let containsViolations = false
  75. const pathnames = snapshot.getFilePathnames()
  76. const diagnostics = []
  77. for (const path of pathnames) {
  78. const file = snapshot.getFile(path)
  79. if (!file) continue
  80. try {
  81. await ensureFileLoaded(file, blobStore, currentVersion, path)
  82. } catch (err) {
  83. console.error(
  84. `Failed to load file ${path} at version ${currentVersion}:`,
  85. err
  86. )
  87. continue
  88. }
  89. const { violations, positions } = checkFileTrackedChanges(
  90. path,
  91. file,
  92. currentVersion
  93. )
  94. if (violations) containsViolations = true
  95. if (positions.length > 0) {
  96. diagnostics.push(` ${path}: changes at [${positions.join(', ')}]`)
  97. }
  98. }
  99. if (diagnostics.length > 0) {
  100. console.log(`Version ${currentVersion} tracked changes summary:`)
  101. console.log(diagnostics.join('\n'))
  102. }
  103. return containsViolations
  104. }
  105. async function validateContentHash(
  106. operation,
  107. snapshot,
  108. currentVersion,
  109. blobStore
  110. ) {
  111. if (operation instanceof core.EditFileOperation) {
  112. const editOperation = operation.getOperation()
  113. if (
  114. editOperation instanceof core.TextOperation &&
  115. editOperation.contentHash != null
  116. ) {
  117. const path = operation.getPathname()
  118. const file = snapshot.getFile(path)
  119. if (file == null) {
  120. console.error(
  121. `VIOLATION: file ${path} not found for hash validation at version ${currentVersion}`
  122. )
  123. return true
  124. }
  125. try {
  126. await ensureFileLoaded(file, blobStore, currentVersion, path)
  127. } catch (err) {
  128. console.error(
  129. `Failed to load file ${path} for hash validation at version ${currentVersion}:`,
  130. err
  131. )
  132. return true
  133. }
  134. const content = file.getContent({ filterTrackedDeletes: true })
  135. const expectedHash = editOperation.contentHash
  136. const actualHash = content != null ? getContentHash(content) : null
  137. if (actualHash !== expectedHash) {
  138. console.error(
  139. `VIOLATION: content hash mismatch in ${path} at version ${currentVersion}: expected ${expectedHash}, got ${actualHash}`
  140. )
  141. return true
  142. }
  143. }
  144. }
  145. return false
  146. }
  147. async function checkChunkChanges(historyId, chunk) {
  148. const snapshot = chunk.getSnapshot().clone()
  149. const blobStore = new BlobStore(historyId)
  150. const changes = chunk.getChanges()
  151. let currentVersion = chunk.getStartVersion()
  152. console.log(
  153. `Checking chunk starting at version ${currentVersion} with ${changes.length} changes.`
  154. )
  155. const initialViolations = await checkSnapshot(
  156. snapshot,
  157. blobStore,
  158. currentVersion
  159. )
  160. if (initialViolations) {
  161. console.log(
  162. `Tracked changes corrupted at initial snapshot version ${currentVersion}`
  163. )
  164. }
  165. for (const change of changes) {
  166. currentVersion += 1
  167. let localViolations = false
  168. if (change?.origin?.kind === 'history-resync') {
  169. console.log('-'.repeat(16), 'history-resync', '-'.repeat(16))
  170. }
  171. console.log(
  172. `Version ${currentVersion} change:`,
  173. JSON.stringify(change.toRaw())
  174. )
  175. if (change?.origin?.kind === 'history-resync') {
  176. process.exit()
  177. }
  178. try {
  179. for (const _operation of change.iterativelyApplyTo(snapshot, {
  180. strict: true,
  181. })) {
  182. console.log(
  183. `Version ${currentVersion} operation:`,
  184. JSON.stringify(_operation.toRaw())
  185. )
  186. const hashErr = await validateContentHash(
  187. _operation,
  188. snapshot,
  189. currentVersion,
  190. blobStore
  191. )
  192. if (hashErr) localViolations = true
  193. }
  194. } catch (err) {
  195. console.error(`Failed to apply change at version ${currentVersion}:`, err)
  196. continue
  197. }
  198. const snapViolations = await checkSnapshot(
  199. snapshot,
  200. blobStore,
  201. currentVersion
  202. )
  203. if (snapViolations) localViolations = true
  204. if (localViolations) {
  205. console.log(
  206. `Tracked changes corrupted or hash mismatch at version ${currentVersion}`
  207. )
  208. console.log('Change was:', JSON.stringify(change.toRaw(), null, 2))
  209. }
  210. }
  211. }
  212. async function main() {
  213. const options = commandLineArgs(optionDefinitions)
  214. const { historyId, version, persistedOnly } = options
  215. if (!historyId) {
  216. console.error('Error: --historyId is required.')
  217. process.exit(1)
  218. }
  219. await loadGlobalBlobs()
  220. if (version != null) {
  221. const chunk = await loadAtVersion(historyId, version, {
  222. persistedOnly: persistedOnly || false,
  223. })
  224. if (!chunk) {
  225. console.error(`Chunk not found at version ${version}`)
  226. process.exit(1)
  227. }
  228. await checkChunkChanges(historyId, chunk)
  229. } else {
  230. let checkedAny = false
  231. for await (const chunkRecord of getProjectChunksFromVersion(historyId, 0)) {
  232. const chunk = await loadAtVersion(historyId, chunkRecord.startVersion, {
  233. persistedOnly: persistedOnly || false,
  234. })
  235. if (chunk) {
  236. checkedAny = true
  237. await checkChunkChanges(historyId, chunk)
  238. } else {
  239. console.error(
  240. `Failed to load chunk starting at ${chunkRecord.startVersion}`
  241. )
  242. }
  243. }
  244. if (!checkedAny) {
  245. console.log(`No chunks found for project ${historyId}`)
  246. }
  247. }
  248. }
  249. main()
  250. .then(() => console.log('Done.'))
  251. .catch(err => {
  252. console.error('Error:', err)
  253. process.exit(1)
  254. })
  255. .finally(() => {
  256. knex.destroy().catch(err => console.error('Error closing Postgres:', err))
  257. client.close().catch(err => console.error('Error closing MongoDB:', err))
  258. redis
  259. .disconnect()
  260. .catch(err => console.error('Error disconnecting Redis:', err))
  261. })